diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore
new file mode 100644
index 0000000..d20c0fe
--- /dev/null
+++ b/.codegraph/.gitignore
@@ -0,0 +1,5 @@
+# CodeGraph data files — local to each machine, not for committing.
+# Ignore everything in .codegraph/ except this file itself, so transient
+# files (the database, daemon.pid, sockets, logs) never show up in git.
+*
+!.gitignore
diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml
index 440c1fd..a1bbf27 100644
--- a/.github/workflows/release-plugin.yml
+++ b/.github/workflows/release-plugin.yml
@@ -3,7 +3,7 @@ name: Publish Plugin to Portal
on:
push:
tags:
- - '*'
+ - 'v*'
permissions:
contents: read
@@ -14,30 +14,37 @@ jobs:
runs-on: ubuntu-latest
environment: release
- env:
- GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
- GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
- if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
steps:
- name: Checkout Repo
- uses: actions/checkout@v2
- - name: Cache Gradle Caches
- uses: actions/cache@v3
- with:
- path: ~/.gradle/caches/
- key: cache-gradle-cache
- - name: Cache Gradle Wrapper
- uses: actions/cache@v3
- with:
- path: ~/.gradle/wrapper/
- key: cache-gradle-wrapper
- - name: Setup java
- uses: actions/setup-java@v3
+ uses: actions/checkout@v4
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
with:
- distribution: 'corretto'
+ distribution: temurin
java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Run tests
+ run: ./gradlew --no-daemon --stacktrace :way-gradle-plugin:test
+
+ - name: Validate tag matches version
+ run: |
+ TAG="${GITHUB_REF#refs/tags/}"
+ VERSION=$(grep '^versionName=' gradle.properties | cut -d'=' -f2)
+ if [ "v${VERSION}" != "${TAG}" ] && [ "${VERSION}" != "${TAG}" ]; then
+ echo "Tag '${TAG}' does not match versionName '${VERSION}' in gradle.properties"
+ exit 1
+ fi
+
- name: Publish on Plugin Portal
- run: ./gradlew --project-dir plugin-build setupPluginUploadFromEnvironment publishPlugins
+ env:
+ GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
+ GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
+ run: ./gradlew setupPluginUploadFromEnvironment :way-gradle-plugin:publishPlugins
if: success()
+
- name: Stop Gradle
run: ./gradlew --stop
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9129a22..84b70e9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,7 @@ name: Release Libraries
on:
push:
tags:
- - '*'
+ - 'v*'
permissions:
contents: read
@@ -37,6 +37,21 @@ jobs:
sdkmanager --install \
"platforms;android-36" \
"build-tools;36.0.0"
+ - name: Run tests
+ run: |
+ ./gradlew --no-daemon --stacktrace \
+ :way-gradle-plugin:test \
+ :way:jvmTest
+
+ - name: Validate tag matches version
+ run: |
+ TAG="${GITHUB_REF#refs/tags/}"
+ VERSION=$(grep '^versionName=' gradle.properties | cut -d'=' -f2)
+ if [ "v${VERSION}" != "${TAG}" ] && [ "${VERSION}" != "${TAG}" ]; then
+ echo "Tag '${TAG}' does not match versionName '${VERSION}' in gradle.properties"
+ exit 1
+ fi
+
- name: Prepare publishing credentials
env:
NEXUS_USERTOKEN_NAME: ${{ secrets.NEXUS_USERTOKEN_NAME }}
diff --git a/.gitignore b/.gitignore
index 6a73a5c..9a60897 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,8 @@ tmp/
build
.gradle
local.properties
+
+/courses/
+/docs/
+AGENTS.md
+CLAUDE.md
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index df543e3..a31c8bc 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,6 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4794fd9..ced04d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,117 @@
# Changelog
+## 0.9.8 - 2026-07-07
+
+The headline of this release is **parallel navigation**, built on the W3C SCXML statechart
+model, together with node lifecycle management, transaction-style transitions, and an expanded
+Compose integration. Parallel support is entirely new — nothing about it changes existing
+single-flow code. A few non-parallel APIs do change; see **Breaking** at the end.
+
+### Parallel navigation (new)
+
+- `ParallelFlowNode` — a new node type (an `abstract class`) whose sub-regions are all
+ **active concurrently** (SCXML AND-states). It can be the schema root or nested inside a
+ region, and returns `FlowTransition` — the same transition set as a regular flow.
+- DOT: declare one with `type = "parallelFlow"` (omitted `resultType` defaults to `kotlin.Unit`).
+- **App-owned Back / focus.** The library stores no "current"/"focused" region — a
+ `ParallelFlowNode` subclass holds its own field and uses it for both rendering and Back. Return
+ `DispatchBackTo(regionId): FlowTransition` from `transition(Event.Back)` to route the
+ structural back-pop into a region; return `Ignore` for the deepest-region default. A
+ stale/unknown region id soft-falls-back to the deepest region — Back never throws.
+- Relative `FlowTarget` / `ScreenTarget` returned from a parallel node resolve against the
+ **first** declared sub-region; target a specific region with `AbsoluteTarget`.
+- `NavigationState` gains `rootNode` / `rootNodePath` so consumers (notably `way-compose`) can
+ find the active root — including a parallel root — without re-walking the schema.
+- New `@CrossRegionEvent` annotation marks events every region must react to (auth expired, deep
+ link, config change); child flows must `Ignore` such events so they bubble to the parallel.
+
+```kotlin
+class MainTabsNode : ParallelFlowNode(), ComposableNode {
+ override val dismissResult = Unit
+ var currentTab: RegionId by mutableStateOf(homeRegionId); private set // app owns "current tab"
+ override fun transition(event: Event) = when (event) {
+ is TabSelected -> { currentTab = event.regionId; Stay }
+ is Event.Back -> DispatchBackTo(currentTab) // Back follows the visible tab
+ else -> Ignore
+ }
+}
+```
+
+### Statechart / SCXML engine + history (new)
+
+- A canonical SCXML entry/exit/LCCA transition engine underpins the runtime, with SCXML
+ conformance tests. Flows are compound (OR) states, parallels are AND-states, screens are atomic.
+- **History** — new `HistoryTarget(path, deep = false)` target plus `type = "history"` (shallow)
+ and `type = "deepHistory"` DOT pseudostates. Shallow restores which child was active (reset to
+ its default leaf); deep restores the exact leaf. Works across parallel regions and cold
+ re-entry, with typed history accessors emitted by codegen.
+
+### Node lifecycle (new)
+
+- `Node.onDispose()` — optional teardown hook (default no-op) on every node type.
+- `NavigationService.cleanDispose()` walks alive nodes leaf-to-root calling `onDispose()` before
+ shutting down; `dispose()` hard-cuts listeners/scheduler and marks the service disposed.
+ Post-dispose `sendEvent` is a safe no-op.
+
+### Transaction-style transitions (new)
+
+- Every transition snapshots `NavigationState` and rolls back atomically if any step (node build,
+ payload computation, schema validation) throws — no half-applied state is ever visible to
+ listeners.
+- New `validateSchema: Boolean = true` flag validates the resulting state against the schema
+ after each transition; set `false` only in tests that intentionally build off-schema states.
+- Payloads persist in navigation state and are pruned in lockstep with alive paths, so a
+ parameterized child flow rebuilt without a fresh `NavigateTo` no longer crashes with
+ `no payload for …`.
+
+### Transitions
+
+- `EnqueueEvent` gains `NavigateAndEnqueue` and the `NavigateTo(...) thenEnqueue event` infix DSL
+ — navigate and queue a follow-up event to dispatch after the navigation completes.
+
+### Compose (`way-compose`)
+
+- A parallel node renders by implementing `ComposableNode` — its `Content()` lays out the parallel
+ (tab bar, pager, …) and calls `NodeHost(regionId)` per sub-region, each in its own
+ `rememberSaveableStateHolder`, so per-node UI state survives sibling switches. `NodeHost` renders
+ parallel roots directly.
+- `NodeHost(regionId)` overload renders a single region of a parallel node.
+- `LocalNavigationService` (a `compositionLocalOf`) and `LocalNodePath` let descendants read the
+ current service and enclosing node path without prop-drilling.
+- `NodeHost`'s `AnimatedContent` is keyed by node **`Path`**, avoiding a same-path re-entry crash
+ and a leak that would otherwise retain every exited node (its DI scope, view models, children)
+ in the SlotTable.
+- `NodeHost(nodeBuilder, onFinishRequest)` now disposes the service it owns (previously leaked it)
+ and reads `onFinishRequest` through `rememberUpdatedState`; `SaveableStateProvider` keys use
+ full, cross-schema-unique segment ids.
+
+### Codegen / DOT plugin
+
+- Segment ids are unified as `nodeId@graphId:file` (via a `SchemaRegistry` that resolves imported
+ nodes to the child schema's identity), so parent and child emit the same id at a module
+ boundary and path comparisons work across Gradle modules.
+- Generated `*Schema` / `*NodeBuilder` expose typed `val RegionId: RegionId` accessors, and
+ parallel-rooted schemas emit a typed region `enum class` (`RegionEnumCodegen`).
+- Build-time schema validation rejects unknown targets, mis-nested regions, duplicate segment ids,
+ disconnected cycles, and codegen output-filename collisions with a precise location.
+- Nullable payload/result types (`parameterType = "kotlin.String?"`) now generate `String?`.
+
+### Breaking (vs 0.9.7)
+
+- `NavigateTo.targets` is now `List` (was `Set`) — replace `setOf(...)` with
+ `listOf(...)`; later targets in the same region win.
+- `NavigationService.start()` throws on a second call; `sendEvent()` throws if called before
+ `start()` (previously an opaque NPE / silent no-op).
+- `NodeBuilder.invalidateCache(path: Path)` → `invalidateCache(alivePaths: Set)` — retains
+ any cache entry that is a prefix of an alive path across **all** regions (fixes sibling-region
+ eviction). Affects custom NodeBuilder / test-double implementations only.
+- `NavigationState` gains required `rootNode` / `rootNodePath` (affects code constructing it
+ directly — usually only tests).
+- `way-compose`: the `NodeHost` `transitionSpec` receiver is now
+ `AnimatedContentTransitionScope` (was over `NodeWithPath?`).
+- Re-run the `way-gradle-plugin` codegen: the segment-id format and new region enums change
+ generated output.
+
## 0.9.7 - 2026-03-31
* Fix Windows-incompatible Android source discovery in `way-gradle-plugin` by avoiding `SourceDirectorySet.directories` snapshot/provider placeholder paths (e.g. `provider(?)`)
diff --git a/README.md b/README.md
index 37400bf..b1a48f3 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
Way is a navigation library built around statechart-like node graphs.
+Way's model is a Harel statechart and maps directly onto the [W3C SCXML standard](https://www.w3.org/TR/scxml/): flows are compound (OR) states, parallel nodes are parallel (AND) states, and screens are atomic states. See [Relationship to statecharts / SCXML](#relationship-to-statecharts--scxml) for the full concept mapping.
+
A graph is defined in `.dot` files, and the `ru.kode.way` Gradle plugin generates:
- schema classes
- typed targets
@@ -13,10 +15,10 @@ The Compose integration (`:way-compose`) renders active nodes with `NodeHost`.
## Modules
-- `:way` - Kotlin Multiplatform runtime library (common + JVM).
+- `:way` - Runtime library structured as a Kotlin Multiplatform project (common + JVM). **Current release targets JVM/Android only. iOS and other native targets are planned for a future release.**
- `:way-compose` - Android Compose integration for `NavigationService`.
- `:way-gradle-plugin` - Gradle plugin (`id("ru.kode.way")`) that generates code from `.dot` schemas.
-- `:sample` - KMP/JVM sample.
+- `:sample` - JVM sample (KMP multi-target support is not yet released).
- `:sample-compose:*` - Android sample split into feature modules.
## Requirements
@@ -90,7 +92,8 @@ digraph App {
- `type`
- `flow` - local flow node.
- `schema` - imported child schema flow.
- - `parallel` - parallel flow node.
+ - `parallelFlow` - parallel flow node (all sub-regions active at once).
+ - `history` / `deepHistory` - a [history pseudostate](#history-resume-across-teardown): a childless leaf declared under a plain flow or a parallel (`parentFlow -> histNodeId`). Generates a typed `HistoryTarget` accessor and is codegen-time-only (it never becomes a runtime `Schema.NodeType`).
- `resultType` - result type for `Finish(...)` from a flow.
- `parameterName` / `parameterType` - typed payload for that node target.
@@ -110,6 +113,7 @@ Typical generated types (from graph id `App`):
- `AppTargets` (+ `Target.Companion.app` accessor)
- `AppNodeBuilder` and nested `AppNodeBuilder.Factory`
- `AppChildFinishRequest` (sealed interface with nested child events, when child flows exist)
+- For parallel nodes, `NodeBuilder` also emits named `val RegionId: RegionId` properties for each sub-region, so callers never need to hardcode `RegionId(Path(...))` strings.
### How source wiring works
@@ -121,18 +125,195 @@ Typical generated types (from graph id `App`):
## Runtime Model
Core runtime types in `:way`:
-- `FlowNode`, `ScreenNode`, `ParallelNode`
+- `FlowNode`, `ScreenNode`, `ParallelFlowNode`
- `NavigationService`
-- `FlowTransition` / `ScreenTransition`
-- `Target` (`FlowTarget`, `ScreenTarget`, `AbsoluteTarget`)
+- `FlowTransition` / `ScreenTransition` (a `ParallelFlowNode` returns `FlowTransition`)
+- `Target` (`FlowTarget`, `ScreenTarget`, `AbsoluteTarget`, `HistoryTarget`)
+
+### Relationship to statecharts / SCXML
+
+Way is a Harel statechart engine. Its runtime model corresponds directly to the
+[W3C SCXML standard](https://www.w3.org/TR/scxml/) — in particular
+[Appendix B, "Algorithm for SCXML Interpretation"](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation).
+If you know SCXML (or UML state machines), the concepts map one-to-one:
+
+| Way concept | SCXML / statechart concept |
+|---|---|
+| `FlowNode` | Compound (OR) state — `` with children; **exactly one** child active at a time |
+| `ParallelFlowNode` | Parallel (AND) state — ``; **all** regions active simultaneously |
+| `ScreenNode` | Atomic state — a leaf `` with no children |
+| `FlowNode.initial` | The default/initial transition of a compound state (``) |
+| Region (sub-region of a parallel node) | Orthogonal region of a `` state |
+| Set of alive paths across all regions | The active **configuration** (the set of currently active states) |
+| Navigating to a deep target auto-enters every intermediate state | Entry-set computation (`computeEntrySet` = `addAncestorStatesToEnter` + `addDescendantStatesToEnter`), scoped by the LCCA |
+| History node (`type="history"` / `"deepHistory"`) → `HistoryTarget` | History pseudostate (``) — restore a state's most-recently-active configuration (see [History](#history-resume-across-teardown)) |
+
+Entering a deep target automatically enters every state on the way in: each
+intermediate compound state contributes its default/initial child, and each
+intermediate parallel state contributes **all** of its regions. This is exactly
+SCXML's entry-set computation, bounded by the **LCCA** (Least Common Compound
+Ancestor) of the transition. Way implements these standard building blocks in
+`StatechartAlgorithm.kt`: `findLCCA`, `getTransitionDomain`, `computeExitSet`,
+`getProperAncestors`, and `entryAncestors` (the schema-static "ancestor fill"
+half of `computeEntrySet`).
+
+#### No "focused region" in standard statecharts
+
+In a true statechart **all parallel regions are active at the same time** —
+there is no notion of a "focused", "selected", or "current" region in the
+control-flow sense. Which region is *visible* is a **presentation** concern,
+orthogonal to the active-state set, and Way keeps it **entirely on the app
+side**: the library stores no "current region". Your `ParallelFlowNode`
+subclass holds its own field (e.g. `var currentTab: RegionId`) and uses it for
+both rendering (in `Content()`) and Back routing — see
+[Parallel back dispatch](#parallel-back-dispatch).
+
+**"Back" is likewise not a statechart concept**: it is layered on as a separate
+navigation facet, independent of the active-state configuration.
+
+### History (resume across teardown)
+
+A **history pseudostate** restores the *most-recently-active* configuration of a flow or parallel
+instead of its default initial. It is the SCXML `` state, exposed as `HistoryTarget`.
+
+**When to reach for it — the one thing nothing else does:** restore **all** regions of a **non-root**
+parallel that was fully torn down and later re-entered. The per-region back-stack and Back only resume
+a region while it is still alive; once a parallel is fully exited its alive chains are gone, so only
+recorded history can bring every region's leaf back.
+
+Declare a childless history node under the flow/parallel whose configuration you want to remember:
+
+```dot
+digraph App {
+ package = "com.example"
+ appFlow [type=flow]
+ homeScreen [type=screen]
+ settingsParallel [type=parallelFlow]
+ settingsHist [type="deepHistory"] // history pseudostate for the parallel
+
+ appFlow -> homeScreen
+ appFlow -> settingsParallel
+ settingsParallel -> settingsHist
+ settingsParallel -> profileTab -> profileScreen -> editProfileScreen
+ settingsParallel -> devicesTab -> devicesScreen -> deviceDetailScreen
+}
+```
+
+The plugin generates a typed accessor in the enclosing flow's `Targets` class, whose path points at the
+remembered flow/parallel (segment ids elided for readability):
+
+```kotlin
+// AppTargets
+val settingsHist: HistoryTarget =
+ HistoryTarget(Path([appFlow, settingsParallel]), deep = true)
+```
+
+Use it in a transition like any other target:
+
+```kotlin
+// appFlow's FlowNode
+override fun transition(event: Event) = when (event) {
+ is OpenSettings -> NavigateTo(Target.appFlow.settingsHist) // resume Settings where the user left off
+ else -> Ignore
+}
+```
+
+Suppose the user drills `profileTab → editProfile` **and** `devicesTab → deviceDetail`, then backs all
+the way out to Home — `settingsParallel` is fully destroyed. Re-opening:
+
+- `NavigateTo(Target.appFlow.settingsParallel)` (a plain `FlowTarget`) **resets both tabs to their
+ defaults** (`profileScreen`, `devicesScreen`).
+- `NavigateTo(Target.appFlow.settingsHist)` (deep history) **restores both tabs at once** —
+ `editProfileScreen` *and* `deviceDetailScreen`. On the very first visit, with nothing recorded yet, it
+ falls back to the default initial, exactly like a `FlowTarget`.
+
+**Shallow vs deep** (`type="history"` vs `type="deepHistory"`) differ only when the remembered child has
+deeper state of its own:
+
+```dot
+onboarding -> wizard -> step1 // step1 is the default
+wizard -> step2
+```
+
+If the user was on `wizard/step2` when `onboarding` was torn down, then re-entering via history:
+
+- `type="deepHistory"` restores `wizard/step2` — the exact leaf you were on.
+- `type="history"` (shallow) restores `wizard` but re-enters it at its default `step1` — it remembers
+ *which* child was active, not how deep you had gone.
+
+They coincide when the child is a plain screen (nothing deeper to forget). For a **parallel**, deep
+history restores every region's exact leaf, whereas shallow re-enters every region at its default (close
+to a plain `FlowTarget`) — so for "resume the tabs exactly where I left off", reach for `deepHistory`.
+
+**When you don't need it.** A **root** parallel (e.g. a bottom-nav bar that is never torn down) already
+keeps every tab alive, so the back-stack resumes each tab for free — no history required. Flows you
+re-compose explicitly (see [Backing out of a cross-tab jump](#backing-out-of-a-cross-tab-jump)) don't
+need it either. Reach for history only for the genuine tear-down-and-return case above.
+
+### Parallel back dispatch
+
+Parallel regions are all active at once, so Back must target **one** of them. Since "which region the user is looking at" is presentation state the library doesn't store, the parallel node decides in its ordinary `transition(Event.Back)`:
+
+- return `DispatchBackTo(regionId)` to route the structural back-pop into that region (supply the region from your own field);
+- return `Stay` to swallow Back, or `Finish(...)` / `NavigateTo(...)` to redirect;
+- return `Ignore` (the default) and Back goes to the **deepest** active region.
+
+```kotlin
+// Tab bar: Back goes within the selected tab. The app owns "current tab".
+class MainTabsNode : ParallelFlowNode(), ComposableNode {
+ var currentTab: RegionId by mutableStateOf(homeRegionId); private set
+
+ override fun transition(event: Event) = when (event) {
+ is TabSelected -> { currentTab = event.regionId; Stay } // update own field
+ is Event.Back -> DispatchBackTo(currentTab) // route Back via own field
+ is LogoutRequested -> Finish(Unit)
+ else -> Ignore
+ }
+}
+```
+
+Pass a `RegionId` from `NavigationState.regions` keys or a generated `.RegionId` constant. A `DispatchBackTo` naming a region that is no longer alive is **not** an error — Back soft-falls-back to the deepest active region, so the back button never crashes.
Transitions:
- `NavigateTo(targets)`
- `Finish(result)`
- `EnqueueEvent(event)`
+- `NavigateAndEnqueue(navigate, events)` — a `NavigateTo` plus follow-up events (build with `navigateTo thenEnqueue event`)
+- `DispatchBackTo(regionId)` — from a `ParallelFlowNode`, route a Back-press into `regionId` (see [Parallel back dispatch](#parallel-back-dispatch))
- `Stay`
- `Ignore` (bubble to parent flow)
+#### Backing out of a cross-tab jump
+
+A common tab-bar pattern: from tab A the user deep-jumps into a screen owned by tab B, then presses
+Back expecting to both **pop** it in region B **and** return to tab A. Because presentation is
+app-side, you drive it from the parallel node — track `currentTab` yourself, cross regions with
+explicit `AbsoluteTarget`s, and compose two hops:
+
+```kotlin
+var currentTab: RegionId by mutableStateOf(tabARegionId); private set
+
+override fun transition(event: Event): FlowTransition = when (event) {
+ // Hop 1: pop the deep screen in region B (still the current tab), then hand off to hop 2.
+ BackFromDeepScreen ->
+ NavigateTo(AbsoluteTarget(tabBHomePath)) thenEnqueue ReturnToTabA(event.target)
+
+ // Hop 2: switch the app's tab to A and navigate A to the target.
+ is ReturnToTabA -> {
+ currentTab = tabARegionId
+ NavigateTo(AbsoluteTarget(tabATargetPath))
+ }
+ else -> Ignore
+}
+```
+
+The app updates `currentTab` itself; the library stores nothing. Cross-region targets are absolute
+(explicit) rather than resolved against a "focused" region. If tab A should simply be restored as the
+user left it, hop 2 collapses to `{ currentTab = tabARegionId; Stay }`. Note the region-B pop is a
+`NavigateTo(AbsoluteTarget(tabBHomePath))`, which **resets** region B to that screen rather than
+popping exactly one entry — fine when the deep screen sits directly on tab B's home, but be aware it
+also drops any intermediate screens.
+
`NavigationService` behavior:
- `start(payload)` sends internal init event and enters root flow(s).
- Keeps `NavigationState` with per-region active/alive node paths.
@@ -142,12 +323,101 @@ Transitions:
## Compose Integration
`way-compose` provides:
-- `ComposableNode` interface with `@Composable fun Content(...)`
-- `NodeHost(service)` composable that:
- - auto-starts service if needed
- - observes active node
- - renders `ComposableNode`
- - applies default animated transitions
+- `ComposableNode` interface with `@Composable fun Content(modifier: Modifier)`
+- A `ParallelFlowNode` renders by implementing `ComposableNode` — its `Content()` lays out the parallel and calls `NodeHost(regionId)` inside it to render each sub-region's screen stack.
+- `LocalNavigationService` — `CompositionLocal>` provided by `NodeHost(service)`. Available inside any `Content()` for reading state or sending events.
+- `NodeHost(service)` composable — auto-starts service, observes root region's active node, renders `ComposableNode.Content()`, applies animated transitions.
+- `NodeHost(regionId)` composable — renders the active screen in a specific sub-region. Call from a parallel node's `Content()` for each sub-region. Requires a parent `NodeHost(service)` to have provided `LocalNavigationService`.
+
+Parallel node example (tab bar with state preservation):
+
+```kotlin
+class MainTabsNode : ParallelFlowNode(), ComposableNode {
+ override val dismissResult = Unit
+ // homeRegionId / exploreRegionId come from generated MainTabsSchema constants.
+ // The app owns "current tab" — the library stores nothing.
+ var currentTab: RegionId by mutableStateOf(homeRegionId); private set
+
+ override fun transition(event: Event) = when (event) {
+ is TabSelected -> { currentTab = event.regionId; Stay } // update own field
+ is Event.Back -> DispatchBackTo(currentTab) // Back follows the visible tab
+ else -> Ignore
+ }
+
+ @Composable
+ override fun Content(modifier: Modifier) {
+ val regionIds = listOf(homeRegionId, exploreRegionId)
+ Scaffold(modifier, bottomBar = { TabBar(selected = currentTab) }) { padding ->
+ // Render all regions simultaneously: state is preserved across tab switches
+ regionIds.forEach { regionId ->
+ key(regionId) {
+ Box(if (regionId == currentTab) Modifier.padding(padding) else Modifier.size(0.dp)) {
+ NodeHost(regionId)
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+## Building a UI integration
+
+The `:way` runtime is UI-agnostic — it contains no rendering code and no UI-framework
+dependencies. `way-compose` is a *reference implementation* of a pluggable UI module, not a
+privileged one: everything it does goes through the public API of `:way`, so an integration for
+another UI framework (Android Views, SwiftUI, desktop, …) can be built the same way as a separate
+`way-view`-style module, with zero changes to the core.
+
+This mirrors the SCXML heritage: the statechart standard deliberately has no presentation
+concept, so all rendering lives outside the core, in per-framework modules.
+
+### The core contract
+
+A UI integration needs only these public APIs:
+
+- **Observe**: `service.addTransitionListener { state: NavigationState -> ... }` — called with the
+ new state after every committed transition (remove the listener with `removeTransitionListener`
+ when the host is torn down). Call `service.start()` on first attach (guard with `isStarted()`).
+- **Read what to render**:
+ - `state.regions[regionId].active` / `.activeNode` — the currently active leaf of each region;
+ this is the node to render for that region.
+ - `state.rootNode` / `state.rootNodePath` — non-null only when the schema's root is a
+ `ParallelFlowNode` (it owns no region of its own; its sub-regions live one level deeper).
+ Render this node's container chrome at the top and host each sub-region inside it.
+- **Tear down**: `service.cleanDispose()` (fires `onDispose()` leaf-to-root and clears listeners)
+ when the host owns the service and leaves; plain hosts just remove their listener.
+
+### The pattern every UI module follows
+
+`way-compose` demonstrates the three pieces an integration provides:
+
+1. **A render opt-in interface** for the framework — `way-compose` has
+ `ComposableNode { @Composable fun Content(modifier) }`; a hypothetical `way-view` would have
+ e.g. `ViewNode { fun createView(context: Context): View }`. Node classes opt into rendering by
+ implementing it; the runtime never sees this interface.
+2. **A host** that observes the service and renders each region's active node when it implements
+ the opt-in (`NodeHost(service)` in `way-compose`).
+3. **A per-region host** so a parallel node's chrome can mount each of its sub-regions
+ (`NodeHost(regionId)` in `way-compose`).
+
+### Why the parallel node renders its own chrome
+
+A regular `FlowNode` (an OR-state) shows exactly one child at a time — there is nothing for it to
+lay out, so it stays headless. A `ParallelFlowNode` (an AND-state) has all sub-regions active
+simultaneously, which raises a question only presentation can answer: *how do N regions share the
+screen* — tabs, side-by-side panes, an overlay sheet? That layout decision belongs to the app, so
+the parallel node's subclass provides it through the UI module's opt-in interface, exactly the way
+screens do. Routing (`transition()`) stays UI-independent either way.
+
+### Keeping node classes UI-framework-free
+
+If your node classes must not depend on any UI framework (shared routing modules, KMP), hold
+presentation state such as the selected tab in a UI-neutral observable — e.g.
+`MutableStateFlow` (`:way` already uses kotlinx-coroutines) instead of Compose's
+`mutableStateOf` — and keep the framework-specific rendering in a module above the routing one.
+The field itself stays on the node either way, because `transition(Event.Back)` uses it for
+`DispatchBackTo(currentTab)`.
## Typical Integration Pattern
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..a0fe9a7
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,52 @@
+# Roadmap
+
+Status of the SCXML rework and what remains. The core is **functionally complete**: canonical
+entry/exit/LCCA engine, all-regions-active parallel model, shallow + deep history (including across
+parallel regions and cold re-entry), typed history codegen, app-side back routing via `DispatchBackTo`,
+SCXML conformance tests, and docs. Nothing below is a broken or missing capability — these are extensions,
+optional cleanups, and
+a future conformance effort.
+
+## Near-term (ready to pick up when there's a need)
+
+### Relative-target unification
+Let relative `FlowTarget`/`ScreenTarget` resolve across schema boundaries so app code rarely needs to
+hand-write an `AbsoluteTarget`. `AbsoluteTarget` stays as the load-bearing engine; this only trims its
+*public* role.
+- **Approach:** BFS fallback in `resolveAbsoluteTargetPath` (`TargetResolution.kt`), scoped to that one
+ file.
+- **Value:** removes cross-schema navigation boilerplate for consumers.
+- **Trigger:** first app site that would otherwise hand-write an `AbsoluteTarget`.
+
+## Feature extensions (on demand)
+
+### History under imported (cross-schema) flows
+The parser currently accepts a history pseudostate under a plain flow or a parallel, but rejects an
+**imported** flow parent (the parent flow lives in another `.dot`).
+- **Cost:** non-trivial — cross-schema path resolution for the recorded configuration.
+- **Trigger:** a real schema that needs to remember an imported sub-flow's configuration.
+
+## Optional internal cleanups (low priority — no behavior change)
+
+These re-implement already-correct, already-shipping code. They carry regression risk for **zero**
+user-facing change, so do them only if the internal consistency is independently worth it.
+
+### `synchronizeNodes` exit path via `computeExitSet`
+Re-express the per-region exit diff in `NavigationService.synchronizeNodes` through the canonical
+`StatechartAlgorithm.computeExitSet`. The current diff is already correct.
+
+## Long-term — full SCXML conformance
+
+The rework deliberately scoped these out; they are a distinct, larger effort:
+- **Guards** on transitions (conditional transitions).
+- **Eventless / automatic** transitions (transitions with no trigger).
+- **Final states** and done events.
+- **Document-order conflict resolution** when multiple transitions are simultaneously enabled.
+
+## Decided — not doing
+
+- **Collapse `history` / `deepHistory` into one.** They are genuinely distinct (deep restores the exact
+ leaf; shallow restores which child, reset to its default — see `HistoryTargetNestedTest`). Both are
+ kept and documented.
+- **Remove `HistoryTarget` / hide it behind an auto-restore flag.** Kept as an explicit target: the
+ teardown-and-resume use case is real and callers want per-navigation shallow-vs-deep control.
diff --git a/gradle.properties b/gradle.properties
index 72ece43..0d03156 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -5,13 +5,13 @@ kotlin.native.ignoreDisabledTargets=true
android.useAndroidX=true
-versionName=0.9.7
+versionName=0.9.8
pomGroupId=ru.kode
pomDescription=Navigation library based on statechart-like node graphs
-pomUrl=https://kode.ru
-pomScmUrl=https://kode.ru
-pomScmConnection=https://kode.ru
-pomScmDevConnection=https://kode.ru
+pomUrl=https://github.com/appKODE/way
+pomScmUrl=https://github.com/appKODE/way
+pomScmConnection=scm:git:https://github.com/appKODE/way.git
+pomScmDevConnection=scm:git:ssh://github.com/appKODE/way.git
pomLicenseName=MIT License
pomLicenseUrl=https://opensource.org/licenses/MIT
pomLicenseDist=repo
diff --git a/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlow.kt b/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlow.kt
index 4ad10f4..fb98c61 100644
--- a/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlow.kt
+++ b/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlow.kt
@@ -2,10 +2,11 @@ package ru.kode.way.sample.compose.main.parallel.routing
import ru.kode.way.sample.compose.category.routing.CategoriesFlow
import ru.kode.way.sample.compose.main.parallel.routing.di.MainParallelFlowComponent
+import ru.kode.way.sample.compose.main.parallel.routing.head.HeadFlow
object MainParallelFlow {
fun nodeBuilder(component: MainParallelFlowComponent): MainParallelNodeBuilder =
MainParallelNodeBuilder(component.nodeFactory(), schema)
- val schema: MainParallelSchema = MainParallelSchema(CategoriesFlow.schema)
+ val schema: MainParallelSchema = MainParallelSchema(HeadFlow.schema, CategoriesFlow.schema)
}
diff --git a/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlowNode.kt b/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlowNode.kt
index b4f9daf..2f3a003 100644
--- a/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlowNode.kt
+++ b/sample-compose/main-parallel/routing/src/main/kotlin/ru/kode/way/sample/compose/main/parallel/routing/MainParallelFlowNode.kt
@@ -1,16 +1,52 @@
package ru.kode.way.sample.compose.main.parallel.routing
-import ru.kode.way.BackDispatchStrategy
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import ru.kode.way.Event
import ru.kode.way.FlowTransition
-import ru.kode.way.ParallelNode
+import ru.kode.way.Ignore
+import ru.kode.way.ParallelFlowNode
+import ru.kode.way.RegionId
+import ru.kode.way.Stay
+import ru.kode.way.compose.ComposableNode
+import ru.kode.way.compose.NodeHost
import javax.inject.Inject
-class MainParallelFlowNode @Inject constructor() : ParallelNode {
- override val backDispatchStrategy: BackDispatchStrategy
- get() = TODO("Not yet implemented")
+class MainParallelFlowNode @Inject constructor() :
+ ParallelFlowNode(),
+ ComposableNode {
+ override val dismissResult = Unit
- override fun transition(event: Event): FlowTransition {
- TODO("not implemented")
+ // Schema-relative RegionIds. NodeHost(regionId) detects it is rendered inside a parallel
+ // (via LocalNodePath) and resolves these to absolute regionIds against the runtime navigation state.
+ private val headRegionId: RegionId get() = MainParallelFlow.schema.regions[0]
+ private val sheetRegionId: RegionId get() = MainParallelFlow.schema.regions[1]
+
+ override fun transition(event: Event): FlowTransition = when (event) {
+ is MainParallelChildFinishRequest.Head,
+ is MainParallelChildFinishRequest.Sheet,
+ -> Stay
+
+ else -> Ignore
+ }
+
+ @OptIn(ExperimentalAnimationApi::class)
+ @Composable
+ override fun Content(modifier: Modifier) {
+ // Render both sub-regions side by side. State in each sub-region is preserved independently
+ // because each NodeHost(regionId) stays in composition simultaneously.
+ Row(modifier = modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
+ NodeHost(regionId = headRegionId)
+ }
+ Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
+ NodeHost(regionId = sheetRegionId)
+ }
+ }
}
}
diff --git a/sample-compose/main-parallel/routing/src/main/way/main-parallel-flow.dot b/sample-compose/main-parallel/routing/src/main/way/main-parallel-flow.dot
index 9f5a9b0..f31aff2 100644
--- a/sample-compose/main-parallel/routing/src/main/way/main-parallel-flow.dot
+++ b/sample-compose/main-parallel/routing/src/main/way/main-parallel-flow.dot
@@ -1,9 +1,9 @@
digraph MainParallel {
package = "ru.kode.way.sample.compose.main.parallel.routing"
- head [type=flow]
+ head [type=schema]
sheet [type=schema]
- mainParallel [type=parallel]
+ mainParallel [type=parallelFlow]
mainParallel -> head
mainParallel -> sheet
}
diff --git a/way-compose/src/main/kotlin/ru/kode/way/compose/ComposableNode.kt b/way-compose/src/main/kotlin/ru/kode/way/compose/ComposableNode.kt
index 8d2ba9c..f01eb47 100644
--- a/way-compose/src/main/kotlin/ru/kode/way/compose/ComposableNode.kt
+++ b/way-compose/src/main/kotlin/ru/kode/way/compose/ComposableNode.kt
@@ -3,6 +3,35 @@ package ru.kode.way.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+/**
+ * Implement this on any [ru.kode.way.Node] that provides its own Compose rendering. [NodeHost]
+ * renders a node by calling [Content] when the node is a [ComposableNode].
+ *
+ * This interface is `way-compose`'s render opt-in — the seam where presentation attaches to the
+ * otherwise UI-agnostic `:way` runtime. Other UI integrations (views, other frameworks) define
+ * their own equivalent opt-in interface and host against the same public core contract; see
+ * "Building a UI integration" in the README.
+ *
+ * A [ru.kode.way.ParallelFlowNode] implements this too: [Content] renders the full parallel layout
+ * (tab bar, pager, drawer + content, …) and calls [NodeHost] with a [ru.kode.way.RegionId] inside it
+ * to render each sub-region's screen stack. To find sub-region ids, use the generated
+ * `val RegionId` constants from the schema/NodeBuilder rather than hardcoding path strings.
+ *
+ * Example — tab bar with state preservation:
+ * ```
+ * override fun Content(modifier: Modifier) {
+ * Scaffold(modifier = modifier, bottomBar = { TabBar(currentTab) }) { padding ->
+ * listOf(homeRegionId, exploreRegionId).forEach { regionId ->
+ * key(regionId) {
+ * Box(if (regionId == currentTab) Modifier.padding(padding) else Modifier.size(0.dp)) {
+ * NodeHost(regionId) // kept in composition → state preserved across tab switches
+ * }
+ * }
+ * }
+ * }
+ * }
+ * ```
+ */
interface ComposableNode {
@Composable
fun Content(modifier: Modifier)
diff --git a/way-compose/src/main/kotlin/ru/kode/way/compose/LocalNavigationService.kt b/way-compose/src/main/kotlin/ru/kode/way/compose/LocalNavigationService.kt
new file mode 100644
index 0000000..fda4955
--- /dev/null
+++ b/way-compose/src/main/kotlin/ru/kode/way/compose/LocalNavigationService.kt
@@ -0,0 +1,29 @@
+package ru.kode.way.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.State
+import androidx.compose.runtime.compositionLocalOf
+import ru.kode.way.NavigationService
+import ru.kode.way.NavigationState
+import ru.kode.way.Path
+
+val LocalNavigationService: ProvidableCompositionLocal> =
+ compositionLocalOf {
+ error(
+ "no NavigationService provided — wrap content with LocalNavigationService.provides(service)",
+ )
+ }
+
+/**
+ * Provides the absolute [Path] of the node currently being rendered by the enclosing [NodeHost].
+ * Read this inside a parallel node's [ComposableNode.Content] body when you need to compute absolute
+ * sub-region RegionIds from schema-relative ones declared in the parallel's own schema.
+ *
+ * Null when no [NodeHost] is in the composition above.
+ */
+val LocalNodePath: ProvidableCompositionLocal = compositionLocalOf { null }
+
+@Composable
+fun NavigationService<*>.collectAsState(): State =
+ produceTransitionState(initial = null) { it }
diff --git a/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt b/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt
index 04255da..fe51a82 100644
--- a/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt
+++ b/way-compose/src/main/kotlin/ru/kode/way/compose/NodeHost.kt
@@ -12,61 +12,267 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.SaveableStateHolder
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Modifier
import ru.kode.way.FlowTransition
import ru.kode.way.NavigationService
import ru.kode.way.NavigationState
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
+import ru.kode.way.ParallelFlowNode
import ru.kode.way.Path
+import ru.kode.way.Region
+import ru.kode.way.RegionId
+import ru.kode.way.append
+import ru.kode.way.drop
import ru.kode.way.startsWith
@ExperimentalAnimationApi
@Composable
fun NodeHost(
service: NavigationService<*>,
- transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = defaultTransitionSpec,
+ transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = defaultTransitionSpec,
) {
LaunchedEffect(service) {
if (!service.isStarted()) {
service.start()
}
}
+ // For parallel-flow-rooted schemas the top-level node is the parallel-flow itself; render its
+ // Content() directly so its `NodeHost(regionId)` calls per sub-region drive the layout. Falls
+ // through to the legacy "render the smallest-path region's active node" path otherwise.
+ //
+ // A single SaveableStateHolder is allocated unconditionally and shared by both branches, and the
+ // branch is selected with a plain if/else (no early `return`). This keeps the slot table stable
+ // when `collectRootNode` flips from its `null` initial value to the resolved root on the first
+ // committed transition — two separate holders reached via early return would tear down and remount
+ // the frame-1 subtree on that flip, discarding transient/saveable state.
+ val rootNodeWithPath by collectRootNode(service)
+ val saveableStateHolder = rememberSaveableStateHolder()
+ CompositionLocalProvider(LocalNavigationService provides service) {
+ val root = rootNodeWithPath
+ val rootNode = root?.node
+ if (root != null && rootNode is ComposableNode) {
+ ComposableNodeContent(rootNode, root.path, Modifier, saveableStateHolder)
+ } else {
+ FirstRegionFallback(service, saveableStateHolder, transitionSpec, root)
+ }
+ }
+}
+
+/**
+ * Fallback render path for a flow-rooted schema (or a parallel root that isn't a [ComposableNode]):
+ * renders the FIRST declared region's active node in an [AnimatedContent]. When a non-ComposableNode
+ * parallel [root] is present, logs the degradation so it isn't silently rendered as first-region only.
+ */
+@ExperimentalAnimationApi
+@Composable
+private fun FirstRegionFallback(
+ service: NavigationService<*>,
+ saveableStateHolder: SaveableStateHolder,
+ transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform,
+ root: NodeWithPath?,
+) {
+ if (root != null) {
+ Log.w(
+ LOG_TAG,
+ "parallel-flow root \"${root.path}\" is not a ComposableNode; only the first region will render — " +
+ "implement ComposableNode on it and call NodeHost(regionId) per sub-region in its Content()",
+ )
+ }
val activeNode by collectActiveNode(service)
- AnimatedContent(
+ NodeAnimatedContent(
+ activeNode = activeNode,
+ contentModifier = Modifier,
+ saveableStateHolder = saveableStateHolder,
transitionSpec = transitionSpec,
- targetState = activeNode,
label = "NodeHost",
- ) { state ->
- if (state != null) {
- val (path, node) = state
- if (node is ComposableNode) {
- (node as ComposableNode).Content(Modifier)
- } else {
- Log.d("way-compose", "didn't find a ComposableNode for \"$path\", rendering an empty content")
- Box {}
+ )
+}
+
+/** Renders [node]'s [ComposableNode.Content] under a [SaveableStateHolder] entry keyed by [path], with [path] exposed via [LocalNodePath]. */
+@Composable
+private fun ComposableNodeContent(
+ node: ComposableNode,
+ path: Path,
+ modifier: Modifier,
+ saveableStateHolder: SaveableStateHolder,
+) {
+ saveableStateHolder.SaveableStateProvider(path.toSaveableKey()) {
+ CompositionLocalProvider(LocalNodePath provides path) {
+ node.Content(modifier)
+ }
+ }
+}
+
+private const val LOG_TAG = "way-compose"
+
+/**
+ * Renders [activeNode] with an [AnimatedContent], releasing every node that leaves.
+ *
+ * The [AnimatedContent] target is the node's [Path] — a lightweight value — NOT the [NodeWithPath]
+ * itself. This is deliberate and load-bearing for memory: a Compose `Transition` retains its
+ * previous state in `segment.initialState` until the next transition starts, and its
+ * currently-visible list keeps outgoing states during the exit animation. Keying on [NodeWithPath]
+ * would therefore strand the whole graph of an already-exited [Node] (its DI scope, view models,
+ * child nodes) in the composition's SlotTable for the entire lifetime of this host — the exact leak
+ * this indirection avoids. Nodes are resolved for rendering from [nodeCache] instead.
+ *
+ * When a keyed content permanently leaves the [AnimatedContent] (its exit transition has finished)
+ * and the node is no longer active, its cached [Node] and its [SaveableStateHolder] entry are
+ * purged, making the exited node weakly reachable so it can be collected.
+ */
+@ExperimentalAnimationApi
+@Composable
+private fun NodeAnimatedContent(
+ activeNode: NodeWithPath?,
+ contentModifier: Modifier,
+ saveableStateHolder: SaveableStateHolder,
+ transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform,
+ label: String,
+) {
+ // key -> Node, for the states AnimatedContent may still render (the active node plus any node
+ // still animating out). Snapshot-backed so a write is observed by the content lambda's read.
+ val nodeCache = remember { mutableStateMapOf() }
+ // Keys currently mounted by AnimatedContent — added on enter, removed once their content (the
+ // exit animation included) leaves. Snapshot-backed so the cleanup effect re-runs when it changes.
+ val mountedKeys = remember { mutableStateListOf() }
+ // Keys we have handed to the SaveableStateHolder, so the cleanup effect knows what to purge. Plain
+ // (non-snapshot) set: only read imperatively inside the effect, never during composition.
+ val trackedKeys = remember { mutableSetOf() }
+
+ val activePath = activeNode?.path
+ val activeKey = activePath?.toSaveableKey()
+ if (activeNode != null && activeKey != null && nodeCache[activeKey] !== activeNode.node) {
+ // Guard the write so an unchanged node does not record a redundant snapshot mutation each frame.
+ nodeCache[activeKey] = activeNode.node
+ }
+
+ AnimatedContent(
+ transitionSpec = transitionSpec,
+ targetState = activePath,
+ contentKey = { it?.toSaveableKey() },
+ label = label,
+ ) { path ->
+ val key = path?.toSaveableKey()
+ val node = key?.let { nodeCache[it] }
+ if (key != null && path != null && node is ComposableNode) {
+ DisposableEffect(key) {
+ trackedKeys.add(key)
+ mountedKeys.add(key)
+ onDispose {
+ mountedKeys.remove(key)
+ nodeCache.remove(key)
+ }
}
+ ComposableNodeContent(node, path, contentModifier, saveableStateHolder)
} else {
+ if (path != null) {
+ if (node is ParallelFlowNode<*>) {
+ Log.w(
+ LOG_TAG,
+ "parallel-flow node at \"$path\" is not a ComposableNode; rendering an empty content — " +
+ "implement ComposableNode on it and call NodeHost(regionId) per sub-region in its Content()",
+ )
+ } else {
+ Log.d(LOG_TAG, "didn't find a ComposableNode for \"$path\", rendering an empty content")
+ }
+ }
Box {}
}
}
+
+ // Purge SaveableStateHolder state for keys whose content has fully left AnimatedContent and are no
+ // longer active (the cached Node itself is already dropped promptly in onDispose above; this also
+ // acts as a backstop for it). Done OUTSIDE the SaveableStateProvider content so removeState runs
+ // AFTER SaveableStateProvider's own dispose-time saveState(): a removeState placed inside the
+ // provider content would be undone, because sibling effects dispose child-before-parent and
+ // saveState() would immediately re-add the entry.
+ LaunchedEffect(mountedKeys.toList(), activeKey) {
+ trackedKeys.toList()
+ .filter { it != activeKey && it !in mountedKeys }
+ .forEach { key ->
+ saveableStateHolder.removeState(key)
+ nodeCache.remove(key)
+ trackedKeys.remove(key)
+ }
+ }
+}
+
+/**
+ * Injective SaveableStateHolder key for a node path. Uses the full [ru.kode.way.Segment.id] of each
+ * segment — NOT [Path.toString], which joins `Segment.name` and strips the `@graphId:file`
+ * disambiguator, collapsing two distinct cross-module paths to the same key and bleeding restored
+ * `rememberSaveable` state from one node into another.
+ */
+private fun Path.toSaveableKey(): String = segments.joinToString(".") { it.id }
+
+/**
+ * Returns the [NavigationState.rootNode] paired with its absolute path, observed reactively.
+ * Non-null only when the schema's root is a [ru.kode.way.ParallelFlowNode]; flow-rooted schemas
+ * stay at `null` (callers fall through to [collectActiveNode]).
+ */
+@Composable
+private fun collectRootNode(service: NavigationService<*>): State =
+ service.produceTransitionState(initial = null) { s ->
+ val node = s.rootNode
+ val path = s.rootNodePath
+ if (node != null && path != null) NodeWithPath(path, node) else null
+ }
+
+/**
+ * A [State] reflecting [transform] applied to every [NavigationState] this service emits (and
+ * `initial` before the first). Registers a transition listener for the composition's lifetime and
+ * removes it on dispose — the listener lifecycle every collector in this module shares. Extra [keys]
+ * (beyond the service) re-key the underlying [produceState].
+ */
+@Composable
+internal fun NavigationService<*>.produceTransitionState(
+ initial: T,
+ vararg keys: Any?,
+ transform: (NavigationState) -> T,
+): State = produceState(initial, this, *keys) {
+ val listener = { s: NavigationState -> value = transform(s) }
+ addTransitionListener(listener)
+ awaitDispose { removeTransitionListener(listener) }
}
+private fun Region.toNodeWithPath(): NodeWithPath = NodeWithPath(active, activeNode)
+
@ExperimentalAnimationApi
@Composable
fun NodeHost(nodeBuilder: NodeBuilder, onFinishRequest: (R) -> FlowTransition) {
- val service = remember(nodeBuilder) { NavigationService(nodeBuilder, onFinishRequest) }
+ // Read the latest onFinishRequest without recreating the service: the service is keyed on
+ // nodeBuilder only, so a new lambda passed on recomposition (the common case for an inline
+ // lambda) would otherwise be ignored and the stale callback kept forever.
+ val currentOnFinishRequest by rememberUpdatedState(onFinishRequest)
+ val service = remember(nodeBuilder) {
+ NavigationService(nodeBuilder) { result: R -> currentOnFinishRequest(result) }
+ }
+ // This overload OWNS the service it creates, so it must release it: cleanDispose() fires
+ // onDispose() leaf-to-root on all alive nodes (freeing their DI/coroutine scopes) and clears all
+ // listeners. Runs when NodeHost leaves composition OR when nodeBuilder changes (the old service is
+ // disposed before remember installs the new one), preventing a node + listener leak.
+ DisposableEffect(service) {
+ onDispose { service.cleanDispose() }
+ }
NodeHost(service)
}
@ExperimentalAnimationApi
-val defaultTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = {
+val defaultTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = {
val initial = initialState
val target = targetState
when {
@@ -78,7 +284,7 @@ val defaultTransitionSpec: AnimatedContentTransitionScope.() -> C
// TODO Calculate transitions based on more clever heuristics. They can require looking into Schema to
// determine least common parent Flow of two paths and also can query actual alive Nodes for hints on
// transitions they desire in ambiguous situations
- if (!(initial.path.length > target.path.length && initial.path.startsWith(target.path))) {
+ if (!(initial.length > target.length && initial.startsWith(target))) {
slideIntoContainer(SlideDirection.Left) togetherWith slideOutOfContainer(SlideDirection.Left)
} else {
slideIntoContainer(SlideDirection.Right) togetherWith slideOutOfContainer(SlideDirection.Right)
@@ -91,18 +297,76 @@ val defaultTransitionSpec: AnimatedContentTransitionScope.() -> C
}
}
+/**
+ * When the schema has multiple flow regions, this returns the first one declared.
+ * For deterministic rendering of every region use the [NodeHost] overload that accepts a [RegionId].
+ */
@Composable
fun collectActiveNode(service: NavigationService<*>): State =
- produceState(initialValue = null, service) {
- val listener = { s: NavigationState ->
- // TODO figure out how to render multiple regions
- value = s.regions.values.first().let { NodeWithPath(it.active, it.activeNode) }
- }
- service.addTransitionListener(listener)
- awaitDispose {
- service.removeTransitionListener(listener)
+ service.produceTransitionState(initial = null) { s ->
+ s.regions.entries.firstOrNull()?.value?.toNodeWithPath()
+ }
+
+@Composable
+fun collectActiveNode(service: NavigationService<*>, regionId: RegionId): State =
+ service.produceTransitionState(initial = null, keys = arrayOf(regionId)) { s ->
+ s.regions[regionId]?.toNodeWithPath()
+ }
+
+/**
+ * Renders the active screen in [regionId].
+ *
+ * Call from a parallel node's [ComposableNode.Content] body to render each sub-region's screen stack.
+ *
+ * Requires BOTH [LocalNavigationService] AND [LocalNodePath] to be provided by a parent
+ * [NodeHost]. The parent [NodeHost] (the one that takes a [NavigationService]) installs both
+ * composition locals automatically: it provides the service via [LocalNavigationService] and the
+ * absolute path of the rendered node via [LocalNodePath]. Without that wrapping parent,
+ * `LocalNavigationService.current` will throw with a clear error.
+ *
+ * When [regionId] is schema-relative (i.e. its path does not already start with the parent path),
+ * it is resolved to the absolute [RegionId] used as the key in [NavigationState.regions], mirroring
+ * the runtime's `absoluteRegionRoot` logic. As a special case, length-1 relative regionIds
+ * (e.g. an imported non-parallel schema) cannot supply a tail to append; the guard at the
+ * `regionId.path.length > 1` check uses the parent path itself as the absolute path, mirroring
+ * `TargetResolution.absoluteRegionRoot` — without it, `Path.drop(1)` would return an empty Path
+ * and the init `check(segments.isNotEmpty())` would throw on first composition.
+ */
+@ExperimentalAnimationApi
+@Composable
+fun NodeHost(
+ regionId: RegionId,
+ modifier: Modifier = Modifier,
+ transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = defaultTransitionSpec,
+) {
+ val service = LocalNavigationService.current
+ val parentPath = LocalNodePath.current
+ // If we're rendered inside a parallel node's Content (parentPath != null) and the supplied
+ // regionId is schema-relative (i.e. its path does not already start with parentPath),
+ // resolve it to the absolute regionId used as the key in NavigationState.regions.
+ // This mirrors the runtime's absoluteRegionRoot logic.
+ val absoluteRegionId = remember(parentPath, regionId) {
+ if (parentPath != null && !regionId.path.startsWith(parentPath)) {
+ // Length-1 relative regionIds (e.g. an imported non-parallel schema) cannot supply a
+ // tail to append; in that case the parent path itself is the absolute path. Mirrors the
+ // guard in TargetResolution.absoluteRegionRoot — without it, Path.drop(1) returns an
+ // empty Path and the init `check(segments.isNotEmpty())` throws on first composition.
+ val tail = if (regionId.path.length > 1) regionId.path.drop(1) else null
+ RegionId(if (tail != null) parentPath.append(tail) else parentPath)
+ } else {
+ regionId
}
}
+ val activeNode by collectActiveNode(service, absoluteRegionId)
+ val saveableStateHolder = rememberSaveableStateHolder()
+ NodeAnimatedContent(
+ activeNode = activeNode,
+ contentModifier = modifier,
+ saveableStateHolder = saveableStateHolder,
+ transitionSpec = transitionSpec,
+ label = "NodeHost-${absoluteRegionId.path}",
+ )
+}
@Immutable
data class NodeWithPath(val path: Path, val node: Node)
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/AdjacencyList.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/AdjacencyList.kt
index 278c90a..2719316 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/AdjacencyList.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/AdjacencyList.kt
@@ -2,6 +2,17 @@ package ru.kode.way.gradle
internal typealias AdjacencyList = Map>
+/**
+ * Iterates only over [Node.Flow.Local] flow nodes, invoking [action] for each.
+ *
+ * [Node.Flow.LocalParallel] is deliberately excluded because parallel nodes require their own
+ * specialised handling (they delegate to child flows via separate regions and do not participate
+ * in the regular finish-event chain). Including them here would cause callers that generate
+ * linear-flow artefacts (e.g. finish-event classes) to emit incorrect code for parallel nodes.
+ *
+ * Contrast with [mapFlow], which intentionally includes [Node.Flow.LocalParallel] so that a
+ * NodeBuilder class is generated for every buildable flow type.
+ */
internal inline fun AdjacencyList.forEachFlow(action: (Node.Flow, List) -> Unit) {
this.forEach { (node, adjacent) ->
if (node is Node.Flow.Local) {
@@ -10,6 +21,21 @@ internal inline fun AdjacencyList.forEachFlow(action: (Node.Flow, List) ->
}
}
+/**
+ * Maps over all locally-owned flow nodes — both [Node.Flow.Local] and [Node.Flow.LocalParallel] —
+ * returning a transformed list.
+ *
+ * [Node.Flow.LocalParallel] is included here because every parallel flow still needs its own
+ * NodeBuilder class (the builder delegates to child-flow builders for each region). Excluding it
+ * would leave parallel flows without a builder, breaking navigation entirely.
+ *
+ * Contrast with [forEachFlow], which intentionally excludes [Node.Flow.LocalParallel] because
+ * callers of that function generate artefacts (e.g. finish-event sealed interfaces) that are only
+ * meaningful for linear flows.
+ *
+ * [Node.Flow.Imported] (schema references) is excluded from both functions because imported flows
+ * are defined — and their builders generated — in their own dot files.
+ */
internal inline fun AdjacencyList.mapFlow(action: (Node.Flow, List) -> T): List {
val out = mutableListOf()
this.forEach { (node, adjacent) ->
@@ -65,16 +91,151 @@ internal fun AdjacencyList.findParentFlow(node: Node): Node? {
return null
}
-internal fun dfs(adjacencyList: AdjacencyList, root: Node, action: (Node) -> Unit) {
- val discovered = ArrayList(adjacencyList.size)
+/**
+ * True when [node] is a [Node.Flow.Local] whose immediate parent is a [Node.Flow.LocalParallel] —
+ * i.e. a LOCAL sub-region flow that owns its own virtual sub-schema.
+ */
+internal fun AdjacencyList.isLocalChildOfParallel(node: Node): Boolean =
+ node is Node.Flow.Local && findParent(node) is Node.Flow.LocalParallel
+
+/** The [Node.Flow.LocalParallel] that is [node]'s immediate parent, or null when the parent isn't a parallel. */
+internal fun AdjacencyList.parallelParentOf(node: Node): Node.Flow.LocalParallel? =
+ findParent(node) as? Node.Flow.LocalParallel
+
+/**
+ * Returns the "region roots" — the entry-point flow of every independently-navigable region in this schema.
+ *
+ * A region root is a direct child of any [Node.Flow.LocalParallel] whose immediate parent is NOT itself a
+ * LocalParallel. For a plain (non-parallel) graph with no such parallel, the single root flow node is the only
+ * region root.
+ *
+ * This intentionally collects region roots at EVERY nesting depth, not just the top parallel's children. A
+ * parallel reached through a linear [Node.Flow.Local] flow (e.g.
+ * `acmeAppFlow[parallelFlow] -> acmeMainFlow[flow] -> acmeTabsFlow[parallelFlow] -> acmeHomeTab/acmeExploreTab`)
+ * starts a NEW region tier, so `acmeHomeTab`/`acmeExploreTab` surface as region roots alongside the top parallel's
+ * own children `acmeMainFlow`/`acmeAuthFlow` — four flat region roots in total. This is CORRECT and required, NOT
+ * over-collection: the runtime region model is FLAT. `NavigationService.materializeRegion` iterates
+ * `schema.regions` once and creates one top-level `Region` per entry, keyed by its full absolute path (region
+ * depth is encoded in the path, never in map nesting), and `pruneOrphanRegions` pins every `schema.regions` entry
+ * for the service lifetime. The generated `AcmeAppFlowSchema.regions` must therefore list all four so the runtime
+ * materialises all four. This is asserted directly by the runtime contract test
+ * "acme-style layout: each region has its own absolute regionId.path anchored at acmeAppFlow"
+ * (`way/src/commonTest/.../ParallelNodeTest.kt`); reducing this
+ * to two would fail that test and break navigation. The same flat set is what `AdjacencyListTest` pins.
+ *
+ * The one exclusion — a parallel whose immediate parent IS another parallel (parallel-in-parallel, e.g.
+ * `main[parallelFlow] -> one[parallelFlow] -> alpha/beta`) — is deliberate and consistent with the flat model:
+ * `one` is itself a region root, so its children `alpha`/`beta` are that parallel's own sub-regions, materialised
+ * within `one`'s virtual sub-schema rather than as top-level regions of `main`. Surfacing them here would
+ * double-count. (Contrast: a parallel under a LOCAL flow is not itself a region root, so ITS children ARE
+ * promoted — that is the `acmeTabsFlow` case above.)
+ *
+ * Note on the finish-event coupling: because these region roots also drive nested-parallel finish-event
+ * discovery, the top schema surfacing `acmeHomeTab`/`acmeExploreTab` is what makes
+ * [buildChildFinishEventFileSpecs] emit the nested parallel's `AcmeTabsFlowChildFinishRequest` interface. That
+ * coupling is a consequence of the flat model, not a defect — see the `createChildFlowFinishRequestEvent` /
+ * `buildChildFinishEventFileSpecs` emission paths.
+ */
+internal fun buildRegionRoots(adjacencyList: AdjacencyList): List {
+ val regionRoots = mutableListOf()
+ adjacencyList.forEach { (node, children) ->
+ if (node is Node.Flow.LocalParallel && adjacencyList.findParent(node) !is Node.Flow.LocalParallel) {
+ regionRoots.addAll(children)
+ }
+ }
+ if (regionRoots.isEmpty()) {
+ regionRoots.add(adjacencyList.findRootNode())
+ }
+ return regionRoots
+}
+
+internal fun dfs(adjacencyList: AdjacencyList, root: Node, action: (Node) -> Unit) =
+ dfsWhile(adjacencyList, root) { node ->
+ action(node)
+ true // always descend
+ }
+
+/**
+ * Returns every non-root [Node.Flow.LocalParallel] in the graph — i.e. every parallel that needs
+ * its own virtual Schema class rather than being folded into the outer schema.
+ *
+ * Includes both:
+ * - parallels whose parent is another [Node.Flow.LocalParallel] (parallel-in-parallel)
+ * - parallels whose parent is a [Node.Flow.Local] (parallel-as-child-of-flow)
+ *
+ * The only LocalParallel excluded is the root of the file's own graph (no parent), which IS the
+ * outer schema and therefore doesn't need a virtual sub-schema.
+ */
+internal fun AdjacencyList.nestedLocalParallels(): List =
+ keys.filterIsInstance()
+ .filter { findParent(it) != null }
+
+/**
+ * Returns every [Node.Flow.Local] whose immediate parent is a [Node.Flow.LocalParallel].
+ *
+ * These are LOCAL sub-region flows (e.g. `mainFlow` inside `appFlow [type=parallelFlow]`,
+ * or `par05Alpha` inside `par05Main [type=parallelFlow]`). Each one needs its own virtual
+ * sub-schema so that its NodeBuilder operates on a schema whose `rootSegment` IS the LOCAL
+ * flow itself — making the `rootSegmentAlias` contract behave the same for LOCAL sub-regions
+ * as it does for IMPORTED sub-regions and nested parallels.
+ *
+ * Without this, descendant path lookups inside the LOCAL flow's NodeBuilder would double the
+ * LOCAL flow's segment (e.g. `Path(par05Alpha, par05Alpha, par05AlphaScreen1)`), because the
+ * parent passes `alias = par05Alpha` to a child that shares the parent's schema.
+ */
+internal fun AdjacencyList.localChildrenOfLocalParallel(): List {
+ val result = mutableListOf()
+ this.forEach { (node, children) ->
+ if (node is Node.Flow.LocalParallel) {
+ children.forEach { child ->
+ if (child is Node.Flow.Local) {
+ result.add(child)
+ }
+ }
+ }
+ }
+ return result
+}
+
+/**
+ * Returns every node in this graph that owns its own virtual sub-schema:
+ * - non-root [Node.Flow.LocalParallel] (nested parallels)
+ * - [Node.Flow.Local] whose immediate parent is a [Node.Flow.LocalParallel]
+ *
+ * Used to drive (a) virtual-schema generation in [buildSpecs] and (b) ownership lookups in
+ * [NodeBuilderCodegen]; flows that walk up to one of these roots use that root's virtual
+ * sub-schema rather than the outer file's schema.
+ */
+internal fun AdjacencyList.virtualSubSchemaRoots(): List = nestedLocalParallels() + localChildrenOfLocalParallel()
+
+/**
+ * Extracts the sub-graph rooted at [root] (all nodes reachable from [root]).
+ * Useful for generating a virtual Schema for a nested [Node.Flow.LocalParallel].
+ */
+internal fun AdjacencyList.subgraphFor(root: Node): AdjacencyList {
+ val result = LinkedHashMap>(size)
+ dfs(this, root) { node ->
+ result[node] = this[node].orEmpty()
+ }
+ return result
+}
+
+/**
+ * DFS variant where [action] returns `true` to descend into a node's children, `false` to skip.
+ * Use this instead of [dfs] when you need to prune branches (e.g. stop at nested LocalParallels).
+ */
+internal fun dfsWhile(adjacencyList: AdjacencyList, root: Node, action: (Node) -> Boolean) {
+ val discovered = HashSet(adjacencyList.size)
val stack = ArrayDeque(adjacencyList.size)
stack.add(root)
while (stack.isNotEmpty()) {
val v = stack.removeLast()
if (!discovered.contains(v)) {
- action(v)
+ val descend = action(v)
discovered.add(v)
- stack.addAll(adjacencyList[v].orEmpty())
+ if (descend) {
+ stack.addAll(adjacencyList[v].orEmpty())
+ }
}
}
}
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Codegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Codegen.kt
index ec56b93..44649a2 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Codegen.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Codegen.kt
@@ -4,27 +4,200 @@ import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.MemberName
import java.io.File
-import java.nio.file.Path
import java.util.Locale
internal fun generate(file: File, projectDir: File, outputDirectory: File, config: CodeGenConfig) {
- buildSpecs(file, projectDir, config).apply {
+ val parseResult = parseSchemaDotFile(file, projectDir)
+ generateFromParseResult(parseResult, outputDirectory, config, SchemaRegistry.from(listOf(parseResult)))
+}
+
+internal fun generateFromParseResult(
+ parseResult: SchemaParseResult,
+ outputDirectory: File,
+ config: CodeGenConfig,
+ registry: SchemaRegistry = SchemaRegistry.from(listOf(parseResult)),
+) {
+ buildSpecs(parseResult, config, registry).apply {
schemaFileSpec.writeTo(outputDirectory)
targetsFileSpec.writeTo(outputDirectory)
- nodeBuilderSpecs.forEach {
- it.writeTo(outputDirectory)
+ nodeBuilderSpecs.forEach { it.writeTo(outputDirectory) }
+ finishEventsFileSpecs.forEach { it.writeTo(outputDirectory) }
+ virtualSchemaFileSpecs.forEach { it.writeTo(outputDirectory) }
+ regionEnumFileSpec?.writeTo(outputDirectory)
+ }
+}
+
+/**
+ * Cross-file resolver: maps a node id (the name a parent uses on a `type=schema` imported node)
+ * to the [SchemaParseResult] of the `.dot` file that actually declares that node as its root.
+ *
+ * Built once per gradle codegen pass from the full list of `.dot` files in the source set, then
+ * consulted by [buildSegmentId] whenever it needs to emit a [Segment] id for an
+ * [Node.Flow.Imported] node. The point is to make parent and child codegen agree on the
+ * **same** segment id for the same logical node — without this, the parent stamps its own
+ * `@file` on the boundary segment while the child stamps the child's, and `Path.endsWith`
+ * comparisons between absolute paths (built by the runtime using parent-side ids) and
+ * schema-local region ids (using child-side ids) fail at every schema mount boundary.
+ *
+ * When an Imported node has no matching `.dot` file in the registry — e.g. tests that pass a
+ * single fixture file with bare Imported references — [resolveSource] returns `null` and the
+ * caller falls back to the owner's identity. This preserves the existing single-file codegen
+ * behavior and is fine for any setup where the child schema is hand-rolled instead of
+ * codegenned.
+ */
+internal class SchemaRegistry private constructor(private val byRootNodeName: Map>) {
+ /**
+ * Returns the unique [SchemaParseResult] whose root node is named [importedNodeName], or `null`
+ * when no schema or multiple schemas match. When the lookup is ambiguous (test source sets
+ * routinely have multiple unrelated fixtures with the same root, but only one is genuinely the
+ * import target), the caller falls back to the owner's identity — same shape as the
+ * pre-unification behavior, scoped by `@file`. Production codebases where each `.dot` lives in
+ * its own feature module have a unique match per root name and benefit from unification.
+ *
+ * Pass [importingPackage] to prefer same-package matches as a disambiguator when multiple
+ * schemas share a root name across separate sub-trees. If exactly one same-package candidate
+ * exists, it wins; otherwise the lookup remains ambiguous and returns `null`.
+ */
+ fun resolveSource(importedNodeName: String, importingPackage: String? = null): SchemaParseResult? {
+ val matches = byRootNodeName[importedNodeName] ?: return null
+ if (matches.size == 1) return matches.single()
+ if (importingPackage != null) {
+ val samePackage = matches.filter { it.customPackage == importingPackage }
+ if (samePackage.size == 1) return samePackage.single()
+ }
+ return null
+ }
+
+ companion object {
+ fun from(parseResults: List): SchemaRegistry {
+ val byRootNodeName = mutableMapOf>()
+ parseResults.forEach { pr ->
+ if (pr.adjacencyList.isEmpty()) return@forEach
+ val rootNode = pr.adjacencyList.findRootNode()
+ byRootNodeName.getOrPut(rootNode.id) { mutableListOf() }.add(pr)
+ }
+ return SchemaRegistry(byRootNodeName)
+ }
+ }
+}
+
+// Enumerates every (package, fileName) pair this code-generator will emit and reports collisions
+// across all generators (schema, targets, NodeBuilder, finish-event interfaces, virtual schemas).
+// NOTE: If FinishEventsCodegen.childFinishRequestInterfaceName or NodeBuilderCodegen's class-naming
+// logic ever changes, this validator must follow — otherwise collision detection will silently miss
+// overlaps.
+internal fun validateNoOutputFileCollisions(parseResults: List, config: CodeGenConfig) {
+ data class OutputKey(val pkg: String, val fileName: String)
+ // Collect each parseResult's outputs into a Set so a single file that triggers the same output
+ // name through multiple code paths (e.g. MainChildFinishRequest once per parallel-region child)
+ // doesn't look like a self-collision. Cross-parseResult duplicates are the real signal.
+ val outputsByOrigin: Map> = parseResults.associate { pr ->
+ val pkg = pr.customPackage ?: config.outputPackageName
+ val outs = mutableSetOf()
+ fun add(fileName: String) {
+ outs.add(OutputKey(pkg, fileName))
+ }
+ add(schemaFileName(pr, config))
+ add(targetsFileName(pr))
+ // NodeBuilders: must match NodeBuilderCodegen which calls mapFlow — excludes Node.Flow.Imported
+ // (imported flows are built from their own .dot file's parseResult).
+ pr.adjacencyList.forEach { (node, _) ->
+ if (node is Node.Flow.Local || node is Node.Flow.LocalParallel) {
+ add(nodeBuilderClassName(node))
+ }
+ }
+ pr.adjacencyList.virtualSubSchemaRoots().forEach { root ->
+ add(virtualSchemaClassName(root))
+ }
+ if (pr.adjacencyList.isNotEmpty()) {
+ val regionRoots = buildRegionRoots(pr.adjacencyList)
+ regionRoots.forEach { rr ->
+ val parallelParent = pr.adjacencyList.parallelParentOf(rr)
+ if (parallelParent != null && rr is Node.Flow) {
+ add(childFinishRequestInterfaceName(parallelParent.id))
+ }
+ add(childFinishRequestInterfaceName(rr.id))
+ }
}
- finishEventsFileSpec?.writeTo(outputDirectory)
+ pr.filePath.toString() to outs
+ }
+ val collisionMap = mutableMapOf>()
+ for ((origin, outs) in outputsByOrigin) {
+ outs.forEach { key -> collisionMap.getOrPut(key) { mutableListOf() }.add(origin) }
+ }
+ val dups = collisionMap.filter { it.value.size > 1 }
+ if (dups.isNotEmpty()) {
+ error(
+ "Multiple DOT schema files produce overlapping output file(s):\n" +
+ dups.entries.joinToString("\n") { (k, v) ->
+ " ${k.pkg}.${k.fileName}.kt ← ${v.joinToString()}"
+ } +
+ "\nResolve by setting unique graphId attributes or renaming nodes.",
+ )
}
}
internal fun buildSpecs(file: File, projectDir: File, config: CodeGenConfig): SchemaOutputSpecs {
val parseResult = parseSchemaDotFile(file, projectDir)
+ return buildSpecs(parseResult, config, SchemaRegistry.from(listOf(parseResult)))
+}
+
+internal fun buildSpecs(
+ parseResult: SchemaParseResult,
+ config: CodeGenConfig,
+ registry: SchemaRegistry,
+): SchemaOutputSpecs {
+ // Roots that own a virtual sub-schema: nested LocalParallels AND LOCAL flows that are direct
+ // children of a LocalParallel. Both kinds need their own schema so that paths inside their
+ // NodeBuilders are anchored at the sub-region root (matching the documented `rootSegmentAlias`
+ // contract). See [virtualSubSchemaRoots] for the rationale.
+ val virtualRoots = parseResult.adjacencyList.virtualSubSchemaRoots()
+ val virtualSchemaSpecs = virtualRoots.map { root ->
+ val subAdjList = parseResult.adjacencyList.subgraphFor(root)
+ val virtualParseResult = parseResult.copy(
+ adjacencyList = subAdjList,
+ // Keep the parent's graphId — virtual sub-schemas represent a slice of the same .dot file,
+ // and their segments must carry the same `@graphId:file` suffix as segments emitted by the
+ // outer schema so absolute paths compare cleanly across boundaries.
+ customSchemaFileName = virtualSchemaClassName(root),
+ customSchemaClassName = virtualSchemaClassName(root),
+ customTargetsFileName = null,
+ )
+ buildSchemaFileSpec(
+ virtualParseResult,
+ config.copy(outputSchemaClassName = virtualSchemaClassName(root)),
+ registry,
+ )
+ }
+ // For each virtual sub-schema root, emit finish-event interfaces for any flow descendants. The
+ // virtual schema's createChildFlowFinishRequestEvent body references e.g. AlphaChildFinishRequest.AlphaSub
+ // (keyed by region-root id inside the virtual graph); without these specs that interface is never emitted.
+ // Pass emitParentParallelEntries=false to avoid duplicating the outer schema's parent-parallel entries.
+ val virtualFinishEventSpecs = virtualRoots.flatMap { root ->
+ val subAdjList = parseResult.adjacencyList.subgraphFor(root)
+ val virtualParseResult = parseResult.copy(
+ adjacencyList = subAdjList,
+ customSchemaFileName = null,
+ customTargetsFileName = null,
+ )
+ buildChildFinishEventFileSpecs(
+ virtualParseResult,
+ config.copy(outputSchemaClassName = virtualSchemaClassName(root)),
+ registry,
+ emitParentParallelEntries = false,
+ )
+ }
+ // For top-level schemas with more than one region (i.e. parallel-rooted), emit a Region enum
+ // so consumers can use a typed identifier for each sub-region instead of comparing RegionId
+ // values directly. Single-region (flow-rooted) schemas have no meaningful enum to emit.
+ val regionEnumSpec = buildRegionEnumFileSpecOrNull(parseResult, config)
return SchemaOutputSpecs(
- schemaFileSpec = buildSchemaFileSpec(parseResult, config),
- targetsFileSpec = buildTargetsFileSpec(parseResult, config),
- nodeBuilderSpecs = buildNodeBuilderFileSpecs(parseResult, config),
- finishEventsFileSpec = buildChildFinishEventFileSpecs(parseResult, config),
+ schemaFileSpec = buildSchemaFileSpec(parseResult, config, registry),
+ targetsFileSpec = buildTargetsFileSpec(parseResult, config, registry),
+ nodeBuilderSpecs = buildNodeBuilderFileSpecs(parseResult, config, registry),
+ finishEventsFileSpecs = buildChildFinishEventFileSpecs(parseResult, config, registry) + virtualFinishEventSpecs,
+ virtualSchemaFileSpecs = virtualSchemaSpecs,
+ regionEnumFileSpec = regionEnumSpec,
)
}
@@ -32,18 +205,64 @@ internal class SchemaOutputSpecs(
val schemaFileSpec: FileSpec,
val targetsFileSpec: FileSpec,
val nodeBuilderSpecs: List,
- val finishEventsFileSpec: FileSpec?,
+ val finishEventsFileSpecs: List,
+ val virtualSchemaFileSpecs: List = emptyList(),
+ val regionEnumFileSpec: FileSpec? = null,
)
+/**
+ * Returns the class name for the virtual Schema generated for a nested [Node.Flow.LocalParallel].
+ * This mirrors the naming used for imported-schema classes (e.g. "Par06AlphaSchema").
+ */
+internal fun virtualSchemaClassName(node: Node): String = node.id.toPascalCase() + "Schema"
+
internal fun libraryMemberName(name: String): MemberName = MemberName(LIBRARY_PACKAGE, name)
internal fun String.toPascalCase(): String = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
}
-internal fun String.toCamelCase(): String = this
+/**
+ * Builds the `Segment.id` string used in generated code. Format:
+ * `@:`.
+ *
+ * `sourceGraphId` is the `digraph X` name from whichever `.dot` file actually defines the node:
+ * - For [Node.Flow.Imported] (i.e. a `type=schema` reference) — the **child** schema's graph id,
+ * resolved via [SchemaRegistry]. This is the key insight: the parent that imports the child
+ * and the child itself must emit the **same** segment id for that boundary node, otherwise
+ * `Path.endsWith` / `Path.startsWith` fail at every schema mount.
+ * - For everything else (Local, LocalParallel, Screen) — the `owner` schema's graph id (the
+ * `.dot` file currently being codegenned).
+ *
+ * If [registry] has no match for an Imported node (single-file test fixtures, hand-rolled child
+ * schemas), this falls back to the `owner`'s identity — same as the pre-unification behavior.
+ */
+internal fun buildSegmentId(node: Node, owner: SchemaParseResult, registry: SchemaRegistry): String {
+ val source: SchemaParseResult = if (node is Node.Flow.Imported) {
+ registry.resolveSource(node.id, importingPackage = owner.customPackage) ?: owner
+ } else {
+ owner
+ }
+ return "${node.id}$SEGMENT_ID_GRAPH_DELIMITER${source.graphId ?: "_"}:${source.filePath}"
+}
+
+/**
+ * Delimiter between the node name and the `:` disambiguator inside a `Segment.id`
+ * (see [buildSegmentId]). [Segment.name]-style consumers strip everything from this character on to
+ * recover the bare node name (e.g. RegionEnumCodegen); keep the two in lockstep.
+ */
+internal const val SEGMENT_ID_GRAPH_DELIMITER = '@'
+
+/** The generated Schema file/class name for [pr] — the custom name when set, else [schemaClassName]. */
+internal fun schemaFileName(pr: SchemaParseResult, config: CodeGenConfig): String =
+ pr.customSchemaFileName ?: schemaClassName(pr, config)
+
+/** The generated Targets file/class name for [pr] — `Targets`, or the custom/default name. */
+internal fun targetsFileName(pr: SchemaParseResult): String =
+ pr.customTargetsFileName ?: (pr.graphId?.let { "${it}Targets" } ?: DEFAULT_TARGETS_FILE_NAME)
-internal fun buildSegmentId(schemaFilePath: Path, node: Node): String = "${node.id}@$schemaFilePath"
+/** The generated NodeBuilder class name for a flow [node] — `NodeBuilder`. */
+internal fun nodeBuilderClassName(node: Node): String = "${node.id.toPascalCase()}NodeBuilder"
internal fun buildPathConstructorCall(nodes: List, buildSegmentId: (Node) -> String): CodeBlock =
CodeBlock.builder()
@@ -54,6 +273,13 @@ internal fun buildPathConstructorCall(nodes: List, buildSegmentId: (Node)
)
.build()
+/**
+ * Emits `Segment("a"), Segment("b"), …` — one `Segment(...)` literal per node in [nodes].
+ *
+ * INVARIANT: the format string and the argument list must stay in lockstep at 2 args per node — each
+ * `%T(%S)` placeholder consumes exactly one `SEGMENT` type and one segment-id string. Changing one
+ * side without the other corrupts the generated output.
+ */
internal fun buildSegmentArgumentList(nodes: List, buildSegmentId: (Node) -> String): CodeBlock =
CodeBlock.builder()
.add(
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FinishEventsCodegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FinishEventsCodegen.kt
index 953e563..6bcdb38 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FinishEventsCodegen.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FinishEventsCodegen.kt
@@ -8,46 +8,71 @@ import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.UNIT
-internal fun buildChildFinishEventFileSpecs(parseResult: SchemaParseResult, config: CodeGenConfig): FileSpec? {
+internal fun buildChildFinishEventFileSpecs(
+ parseResult: SchemaParseResult,
+ config: CodeGenConfig,
+ @Suppress("UNUSED_PARAMETER") registry: SchemaRegistry,
+ emitParentParallelEntries: Boolean = true,
+): List {
val packageName = parseResult.customPackage ?: config.outputPackageName
- val rootNode = parseResult.adjacencyList.findRootNode()
+ val adjacencyList = parseResult.adjacencyList
+ val regionRoots = buildRegionRoots(adjacencyList)
- // all "local" flows are considered to be children of the root flow in the dot file
- // (remember! dot file graph specifies backstack, not structure!)
- // Therefore only one "ChildEvents"-class must be generated per dot-file.
- // Complex multiple flows in one dot file are not permitted, better to factor out logic in multiple dot files in
- // this case.
- // See NOTE_GROUPING_NODES_BY_FLOW_RULE
+ // Collect (parentInterfaceId -> ordered list of child flow nodes) for each interface to generate.
+ // Two sources contribute:
+ // 1. For each regionRoot R that is a flow child of a LocalParallel P: R contributes to PChildFinishRequest.
+ // Skipped when [emitParentParallelEntries] is false (e.g. when generating finish-event specs for a
+ // virtual sub-schema, where the outer schema is responsible for emitting the parent's entries).
+ // 2. For each regionRoot R: flows discovered by DFS from R contribute to RChildFinishRequest.
+ val interfaceGroups = mutableMapOf>()
- val childFlowNodes = mutableListOf()
- dfs(parseResult.adjacencyList, rootNode) { node ->
- if (node == rootNode) return@dfs
- when (node) {
- is Node.Flow -> {
- childFlowNodes.add(node)
+ regionRoots.forEach { regionRoot ->
+ if (emitParentParallelEntries) {
+ val parallelParent = adjacencyList.parallelParentOf(regionRoot)
+ if (parallelParent != null && regionRoot is Node.Flow) {
+ interfaceGroups.getOrPut(parallelParent.id) { mutableListOf() }.add(regionRoot)
}
-
- is Node.Screen -> Unit
+ }
+ // A regionRoot that is a LOCAL flow child of a LocalParallel owns its own virtual sub-schema
+ // — that sub-schema is responsible for emitting `ChildFinishRequest`. Skip the
+ // outer DFS to avoid generating a duplicate (and colliding) interface here.
+ if (adjacencyList.isLocalChildOfParallel(regionRoot)) {
+ return@forEach
+ }
+ dfsWhile(adjacencyList, regionRoot) { node ->
+ if (node != regionRoot && node is Node.Flow) {
+ interfaceGroups.getOrPut(regionRoot.id) { mutableListOf() }.add(node)
+ }
+ // Stop descent at any node that has its own virtual sub-schema — that sub-schema emits
+ // its own child interfaces. Without this prune we would attribute deeply-nested flows to
+ // the wrong parent (their grandparent's interface).
+ val ownsVirtualSubSchema = node is Node.Flow.LocalParallel || adjacencyList.isLocalChildOfParallel(node)
+ node === regionRoot || !ownsVirtualSubSchema
}
}
- if (childFlowNodes.isEmpty()) {
- return null
- }
+ return interfaceGroups
+ .filterValues { it.isNotEmpty() }
+ .map { (parentId, childFlows) ->
+ buildFinishEventFileSpec(packageName, parentId, childFlows.distinctBy { it.id })
+ }
+}
- val className = ClassName(packageName, childFinishRequestInterfaceName(rootNode.id))
+private fun buildFinishEventFileSpec(
+ packageName: String,
+ parentId: String,
+ childFlowNodes: List,
+): FileSpec {
+ val className = ClassName(packageName, childFinishRequestInterfaceName(parentId))
return FileSpec
- .builder(
- packageName,
- className.simpleName,
- )
+ .builder(packageName, className.simpleName)
.addType(
TypeSpec.interfaceBuilder(className)
.addModifiers(KModifier.SEALED)
.addSuperinterface(EVENT)
.apply {
childFlowNodes.forEach { node ->
- val resultClassName = ClassName.bestGuess(node.resultType)
+ val resultClassName = parseTypeName(node.resultType)
if (resultClassName != UNIT) {
addType(
TypeSpec.classBuilder(node.id.toPascalCase())
@@ -82,4 +107,4 @@ internal fun buildChildFinishEventFileSpecs(parseResult: SchemaParseResult, conf
internal fun childFinishRequestInterfaceName(nodeId: String) = nodeId.toPascalCase() + "ChildFinishRequest"
internal fun childFinishRequestEventClassName(packageName: String, flowNodeId: String, childFlowNodeId: String) =
- ClassName(packageName, childFinishRequestInterfaceName(flowNodeId) + '.' + childFlowNodeId.toPascalCase())
+ ClassName(packageName, childFinishRequestInterfaceName(flowNodeId)).nestedClass(childFlowNodeId.toPascalCase())
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 522f62e..1dfeaa7 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
@@ -45,18 +45,24 @@ abstract class GenerateClassesTask : SourceTask() {
fun generate() {
val output = outputDirectory.get().asFile
val projectDir = projectDirectory.get().asFile
+ val config = CodeGenConfig(outputPackageName = packageName, outputSchemaClassName = outputSchemaClassName)
+ output.deleteRecursively()
+ output.mkdirs()
logger.debug("generation started")
- source.forEach { file ->
- logger.debug("generating classes from schema file: $file")
- generate(
- file,
- projectDir,
- output,
- CodeGenConfig(
- outputPackageName = packageName,
- outputSchemaClassName = outputSchemaClassName,
- ),
- )
+ val files = source.toList()
+ // Parse all files first so we can detect output filename collisions before writing anything.
+ val parseResults = files.map { file ->
+ logger.debug("parsing schema file: $file")
+ parseSchemaDotFile(file, projectDir, warn = logger::warn)
+ }
+ validateNoOutputFileCollisions(parseResults, config)
+ // 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`.
+ val registry = SchemaRegistry.from(parseResults)
+ parseResults.forEach { parseResult ->
+ logger.debug("generating classes from schema: ${parseResult.graphId ?: ""}")
+ generateFromParseResult(parseResult, output, config, registry)
}
}
}
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/LibraryClassNames.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/LibraryClassNames.kt
index 4002cba..61aba1a 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/LibraryClassNames.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/LibraryClassNames.kt
@@ -1,13 +1,16 @@
package ru.kode.way.gradle
import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.TypeName
+val EVENT = ClassName(LIBRARY_PACKAGE, "Event")
val FLOW_NODE = ClassName(LIBRARY_PACKAGE, "FlowNode")
val FLOW_TARGET = ClassName(LIBRARY_PACKAGE, "FlowTarget")
val FLOW_TRANSITION = ClassName(LIBRARY_PACKAGE, "FlowTransition")
+val HISTORY_TARGET = ClassName(LIBRARY_PACKAGE, "HistoryTarget")
val NODE = ClassName(LIBRARY_PACKAGE, "Node")
val NODE_BUILDER = ClassName(LIBRARY_PACKAGE, "NodeBuilder")
-val PARALLEL_NODE = ClassName(LIBRARY_PACKAGE, "ParallelNode")
+val PARALLEL_FLOW_NODE = ClassName(LIBRARY_PACKAGE, "ParallelFlowNode")
val PATH = ClassName(LIBRARY_PACKAGE, "Path")
val REGION_ID = ClassName(LIBRARY_PACKAGE, "RegionId")
val SCHEMA = ClassName(LIBRARY_PACKAGE, "Schema")
@@ -15,4 +18,16 @@ val SCREEN_NODE = ClassName(LIBRARY_PACKAGE, "ScreenNode")
val SCREEN_TARGET = ClassName(LIBRARY_PACKAGE, "ScreenTarget")
val SEGMENT = ClassName(LIBRARY_PACKAGE, "Segment")
val TARGET = ClassName(LIBRARY_PACKAGE, "Target")
-val EVENT = ClassName(LIBRARY_PACKAGE, "Event")
+
+/**
+ * Converts a raw DOT type string (e.g. "kotlin.String" or "kotlin.String?") into a KotlinPoet
+ * [TypeName]. A trailing '?' marks the type nullable. Non-null types are unchanged.
+ */
+internal fun parseTypeName(rawType: String): TypeName {
+ val trimmed = rawType.trim()
+ return if (trimmed.endsWith("?")) {
+ ClassName.bestGuess(trimmed.dropLast(1).trim()).copy(nullable = true)
+ } else {
+ ClassName.bestGuess(trimmed)
+ }
+}
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/NodeBuilderCodegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/NodeBuilderCodegen.kt
index 460cc8f..50d6265 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/NodeBuilderCodegen.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/NodeBuilderCodegen.kt
@@ -13,17 +13,37 @@ import com.squareup.kotlinpoet.MemberName
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.SET
import com.squareup.kotlinpoet.STAR
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.TypeVariableName
-import java.nio.file.Path
-internal fun buildNodeBuilderFileSpecs(parseResult: SchemaParseResult, config: CodeGenConfig): List {
+internal fun buildNodeBuilderFileSpecs(
+ parseResult: SchemaParseResult,
+ config: CodeGenConfig,
+ registry: SchemaRegistry,
+): List {
val packageName = parseResult.customPackage ?: config.outputPackageName
- val rootNode = parseResult.adjacencyList.findRootNode()
- val schemaClassName = ClassName(packageName, schemaClassName(parseResult, config))
- return parseResult.adjacencyList.mapFlow { flow, _ ->
- val className = ClassName(packageName, flow.id.toPascalCase() + "NodeBuilder")
+ val mainAdjList = parseResult.adjacencyList
+ val mainRootNode = mainAdjList.findRootNode()
+ val mainSchemaClassName = ClassName(packageName, schemaClassName(parseResult, config))
+
+ // Every node that owns a virtual sub-schema — nested LocalParallels plus LOCAL flow children of
+ // LocalParallel — needs its NodeBuilder generated against that sub-schema's view of the graph.
+ val virtualRoots: Set = mainAdjList.virtualSubSchemaRoots().toSet()
+ val virtualSubgraphs: Map = virtualRoots.associateWith { mainAdjList.subgraphFor(it) }
+
+ return mainAdjList.mapFlow { flow, _ ->
+ val className = ClassName(packageName, nodeBuilderClassName(flow))
+ val owner = resolveNodeBuilderOwner(
+ flow,
+ mainAdjList,
+ mainSchemaClassName,
+ mainRootNode,
+ virtualRoots,
+ virtualSubgraphs,
+ packageName,
+ )
FileSpec
.builder(
packageName,
@@ -33,25 +53,69 @@ internal fun buildNodeBuilderFileSpecs(parseResult: SchemaParseResult, config: C
buildNodeBuilderTypeSpec(
flow = flow,
className = className,
- schemaClassName = schemaClassName,
- adjacencyList = parseResult.adjacencyList,
- isRootNode = rootNode == flow,
- schemaFilePath = parseResult.filePath,
+ schemaClassName = owner.schemaClassName,
+ adjacencyList = owner.adjacencyList,
+ isRootNode = flow == owner.rootNode,
+ parseResult = parseResult,
+ registry = registry,
),
)
.build()
}
}
+/** The schema view a flow's NodeBuilder is generated against — its own virtual sub-schema, or the main schema. */
+private data class NodeBuilderOwner(
+ val adjacencyList: AdjacencyList,
+ val schemaClassName: ClassName,
+ val rootNode: Node,
+)
+
+/**
+ * Resolves the [NodeBuilderOwner] for [flow]: the innermost ancestor (inclusive) that owns a virtual
+ * sub-schema — whose graph/schema/root drive the flow's path lookups (`schema.target`,
+ * `schema.nodeType`) — or the main outer schema when [flow] belongs to no virtual sub-schema.
+ */
+private fun resolveNodeBuilderOwner(
+ flow: Node,
+ mainAdjList: AdjacencyList,
+ mainSchemaClassName: ClassName,
+ mainRootNode: Node,
+ virtualRoots: Set,
+ virtualSubgraphs: Map,
+ packageName: String,
+): NodeBuilderOwner {
+ val owner = findOwnerVirtualSubSchemaRoot(flow, mainAdjList, virtualRoots)
+ ?: return NodeBuilderOwner(mainAdjList, mainSchemaClassName, mainRootNode)
+ return NodeBuilderOwner(
+ adjacencyList = virtualSubgraphs[owner]!!,
+ schemaClassName = ClassName(packageName, virtualSchemaClassName(owner)),
+ rootNode = owner,
+ )
+}
+
+/**
+ * Returns the innermost ancestor (inclusive) of [flow] that owns a virtual sub-schema, or `null`
+ * when [flow] belongs to the main outer schema.
+ */
+private fun findOwnerVirtualSubSchemaRoot(flow: Node, mainAdjList: AdjacencyList, virtualRoots: Set): Node? {
+ var current: Node = flow
+ while (true) {
+ if (current in virtualRoots) return current
+ current = mainAdjList.findParent(current) ?: return null
+ }
+}
+
internal fun buildNodeBuilderTypeSpec(
flow: Node.Flow,
className: ClassName,
schemaClassName: ClassName,
adjacencyList: AdjacencyList,
isRootNode: Boolean,
- schemaFilePath: Path,
+ parseResult: SchemaParseResult,
+ registry: SchemaRegistry,
): TypeSpec {
- fun buildSegmentId(node: Node): String = "${node.id}@$schemaFilePath"
+ fun buildSegmentId(node: Node): String = buildSegmentId(node, parseResult, registry)
val typeSpecBuilder = TypeSpec.classBuilder(className)
val constructorBuilder = FunSpec.constructorBuilder()
@@ -61,19 +125,17 @@ internal fun buildNodeBuilderTypeSpec(
val factoryBuilderTypeName = className.nestedClass("Factory")
val factoryTypeSpecBuilder = TypeSpec.interfaceBuilder(factoryBuilderTypeName)
.addFunction(
- FunSpec.builder(NODE_FACTORY_FLOW_NODE_BUILDER_NAME)
+ FunSpec.builder(ROOT_NODE_FACTORY_METHOD_NAME)
.addModifiers(KModifier.ABSTRACT)
.returns(
when (flow) {
is Node.Flow.Local -> FLOW_NODE.parameterizedBy(STAR)
- is Node.Flow.LocalParallel -> PARALLEL_NODE
+ is Node.Flow.LocalParallel -> PARALLEL_FLOW_NODE.parameterizedBy(STAR)
is Node.Flow.Imported -> error("unexpected node type: ${flow::class.simpleName}")
},
)
.apply {
- if (flow.parameter != null) {
- addParameter(flow.parameter!!.name, ClassName.bestGuess(flow.parameter!!.type))
- }
+ flow.parameter?.let { param -> addParameter(param.name, parseTypeName(param.type)) }
}
.build(),
)
@@ -101,13 +163,12 @@ internal fun buildNodeBuilderTypeSpec(
.build()
constructorBuilder.addParameter(schemaParameter)
typeSpecBuilder.addProperty(schemaProperty)
- val builderCachePropertyName = "nodeBuilders"
- dfs(adjacencyList, flow) { node ->
- if (node == flow) return@dfs
- // See NOTE_GROUPING_NODES_BY_FLOW_RULE
- if (node is Node.Screen && adjacencyList.findParentFlow(node) != flow) return@dfs
- if (node is Node.Flow && !isRootNode) return@dfs
+ dfsWhile(adjacencyList, flow) { node ->
+ if (node == flow) return@dfsWhile true // skip root, but DO descend
+ val shouldDescend = shouldDescendInto(node, flow)
+ // See NOTE_GROUPING_NODES_BY_FLOW_RULE — foreign nodes are handled by their own flow's NodeBuilder.
+ if (isForeignToFlowScope(node, flow, isRootNode, adjacencyList)) return@dfsWhile shouldDescend
when (node) {
is Node.Flow -> {
val flowFactoryName = "create${node.id.toPascalCase()}NodeBuilder"
@@ -116,14 +177,12 @@ internal fun buildNodeBuilderTypeSpec(
.addModifiers(KModifier.ABSTRACT)
.returns(NODE_BUILDER)
.apply {
- if (node.parameter != null) {
- addParameter(node.parameter!!.name, ClassName.bestGuess(node.parameter!!.type))
- }
+ node.parameter?.let { param -> addParameter(param.name, parseTypeName(param.type)) }
}
.build(),
)
val lazyPropertyBuilderFun = FunSpec
- .builder("${node.id.toCamelCase()}NodeBuilder")
+ .builder("${node.id}NodeBuilder")
.addModifiers(KModifier.PRIVATE)
.returns(NODE_BUILDER)
.apply {
@@ -134,8 +193,8 @@ internal fun buildNodeBuilderTypeSpec(
.addParameter("rootSegmentAlias", SEGMENT.copy(nullable = true))
.beginControlFlow(
"return %L.getOrPut(%L(%T(%S), rootSegmentAlias))",
- builderCachePropertyName,
- GET_TARGET_FUN_NAME,
+ NODE_BUILDER_CACHE_PROPERTY_NAME,
+ TARGET_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
)
@@ -144,7 +203,7 @@ internal fun buildNodeBuilderTypeSpec(
addStatement(
"nodeFactory.%L(%L(%T(%S), payloads, rootSegmentAlias))",
flowFactoryName,
- GET_PAYLOAD_FUN_NAME,
+ PAYLOAD_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
)
@@ -166,14 +225,18 @@ internal fun buildNodeBuilderTypeSpec(
.returns(SCREEN_NODE)
.apply {
if (node.parameter != null) {
- addParameter(node.parameter.name, ClassName.bestGuess(node.parameter.type))
+ addParameter(node.parameter.name, parseTypeName(node.parameter.type))
}
}
.build()
factoryTypeSpecBuilder.addFunction(screenBuilderFunSpec)
nodeBuilders[node] = screenBuilderFunSpec
}
+
+ // History nodes are never built: no factory method, no lazy builder.
+ is Node.History -> Unit
}
+ shouldDescend
}
return typeSpecBuilder
.primaryConstructor(constructorBuilder.build())
@@ -181,7 +244,7 @@ internal fun buildNodeBuilderTypeSpec(
if (lazyNodeBuilderFactories.isNotEmpty()) {
val builderCacheProperty = PropertySpec
.builder(
- builderCachePropertyName,
+ NODE_BUILDER_CACHE_PROPERTY_NAME,
MUTABLE_MAP.parameterizedBy(PATH, NODE_BUILDER),
KModifier.PRIVATE,
)
@@ -194,6 +257,28 @@ internal fun buildNodeBuilderTypeSpec(
}
}
.addType(factoryTypeSpecBuilder.build())
+ .apply {
+ // For parallel nodes: emit named val RegionId constants so users never need
+ // to hardcode Path strings for sub-region lookups.
+ if (flow is Node.Flow.LocalParallel) {
+ adjacencyList[flow].orEmpty().forEach { child ->
+ addProperty(
+ PropertySpec
+ .builder("${child.id}RegionId", REGION_ID)
+ .getter(
+ FunSpec.getterBuilder()
+ .addCode(
+ "return %T(%L)",
+ REGION_ID,
+ buildPathConstructorCall(reversedParents(child, adjacencyList)) { node -> buildSegmentId(node) },
+ )
+ .build(),
+ )
+ .build(),
+ )
+ }
+ }
+ }
.addSuperinterface(NODE_BUILDER)
.addFunction(
FunSpec.builder("build")
@@ -209,7 +294,8 @@ internal fun buildNodeBuilderTypeSpec(
lazyNodeBuilderFactories,
nodeBuilders,
isRootNode,
- schemaFilePath,
+ parseResult,
+ registry,
),
)
.build(),
@@ -217,29 +303,23 @@ internal fun buildNodeBuilderTypeSpec(
.addFunction(
FunSpec.builder("invalidateCache")
.addModifiers(KModifier.OVERRIDE)
- .addParameter("path", PATH)
+ .addParameter("alivePaths", SET.parameterizedBy(PATH))
.apply {
- val includeDebug = true
if (lazyNodeBuilderFactories.isNotEmpty()) {
- if (includeDebug) {
- beginControlFlow(
- "%L.keys.filter { !path.%M(it) }.forEach",
- builderCachePropertyName,
- MemberName(LIBRARY_PACKAGE, "startsWith"),
- )
- addStatement("println(%P)", "\${this::class.simpleName}: removing nodeBuilder for \$it")
- endControlFlow()
- }
addStatement(
- "%L.keys.retainAll { path.%M(it) }",
- builderCachePropertyName,
+ "%L.keys.retainAll·{·key·->·alivePaths.any·{·it.%M(key)·}·}",
+ NODE_BUILDER_CACHE_PROPERTY_NAME,
MemberName(LIBRARY_PACKAGE, "startsWith"),
)
- beginControlFlow("%L.forEach { (builderPath, builder) ->", builderCachePropertyName)
+ beginControlFlow("%L.forEach·{·(builderPath,·builder)·->", NODE_BUILDER_CACHE_PROPERTY_NAME)
+ addStatement("val·drop·=·builderPath.length·-·1")
addStatement(
- "builder.invalidateCache(path.%M(builderPath.length - 1))",
+ "val·childAlive·=·alivePaths.filter·{·it.%M(builderPath)·&&·it.length·>·drop·}" +
+ ".map·{·it.%M(drop)·}.toSet()",
+ MemberName(LIBRARY_PACKAGE, "startsWith"),
MemberName(LIBRARY_PACKAGE, "drop"),
)
+ addStatement("builder.invalidateCache(childAlive)")
endControlFlow()
} else {
addStatement("return Unit")
@@ -247,20 +327,45 @@ internal fun buildNodeBuilderTypeSpec(
}
.build(),
)
- .addFunction(buildGetTargetFunSpec())
- .addFunction(buildGetPayloadBySegmentIdFunSpec())
+ .addFunction(buildTargetOrErrorFunSpec())
+ .addFunction(buildPayloadOrErrorFunSpec())
+ .apply {
+ // Only emit the path-keyed payload helper when the root flow has a parameter — that's
+ // the sole call site (the root branch of build()). Skipping it for parameter-less roots
+ // keeps the generated NodeBuilder minimal.
+ if (flow.parameter != null) {
+ addFunction(buildRootPayloadOrErrorFunSpec())
+ }
+ }
.build()
}
+/**
+ * Whether a dfsWhile scoped to [flow]'s subtree should descend into [node]'s children. A nested
+ * LocalParallel owns its own NodeBuilder, so it is processed but NOT descended into; everything else
+ * is descended.
+ */
+private fun shouldDescendInto(node: Node, flow: Node): Boolean = !(node is Node.Flow.LocalParallel && node != flow)
+
+/**
+ * True when [node] is FOREIGN to a dfsWhile scoped to [flow] and must be skipped for processing (but
+ * still descended): a screen whose nearest parent flow isn't [flow], or a non-root flow other than
+ * [flow] itself. Such nodes are emitted by their own flow's NodeBuilder.
+ */
+private fun isForeignToFlowScope(node: Node, flow: Node, isRootNode: Boolean, adjacencyList: AdjacencyList): Boolean =
+ (node is Node.Screen && adjacencyList.findParentFlow(node) != flow) ||
+ (node is Node.Flow && !isRootNode && node != flow)
+
private fun createBuildFunctionBody(
- flow: Node,
- adjacencyList: Map>,
+ flow: Node.Flow,
+ adjacencyList: AdjacencyList,
lazyNodeBuilderFactories: Map,
nodeBuilders: Map,
isRootNode: Boolean,
- schemaFilePath: Path,
+ parseResult: SchemaParseResult,
+ registry: SchemaRegistry,
): CodeBlock {
- fun buildSegmentId(node: Node): String = "${node.id}@$schemaFilePath"
+ fun buildSegmentId(node: Node): String = buildSegmentId(node, parseResult, registry)
return CodeBlock.builder()
.addStatement(
@@ -279,39 +384,38 @@ private fun createBuildFunctionBody(
.endControlFlow()
.beginControlFlow("return when")
.apply {
- dfs(adjacencyList, flow) { node ->
- // See NOTE_GROUPING_NODES_BY_FLOW_RULE
- if (node is Node.Screen && adjacencyList.findParentFlow(node) != flow) return@dfs
- if (node is Node.Flow && !isRootNode) return@dfs
+ dfsWhile(adjacencyList, flow) { node ->
+ val shouldDescend = shouldDescendInto(node, flow)
+ // See NOTE_GROUPING_NODES_BY_FLOW_RULE — foreign nodes route through their own flow's NodeBuilder.
+ if (isForeignToFlowScope(node, flow, isRootNode, adjacencyList)) return@dfsWhile shouldDescend
when (node) {
is Node.Flow -> {
if (node == flow) {
if (node.parameter != null) {
addStatement(
- "path == rootPath -> %L.%L(%L(rootPath.%M(), payloads, rootSegmentAlias))",
+ "path == rootPath -> %L.%L(%L(rootPath, payloads))",
NODE_FACTORY_PARAMETER_NAME,
- NODE_FACTORY_FLOW_NODE_BUILDER_NAME,
- GET_PAYLOAD_FUN_NAME,
- libraryMemberName("firstSegment"),
+ ROOT_NODE_FACTORY_METHOD_NAME,
+ ROOT_PAYLOAD_OR_ERROR_FUN_NAME,
)
} else {
addStatement(
"path == rootPath -> %L.%L()",
NODE_FACTORY_PARAMETER_NAME,
- NODE_FACTORY_FLOW_NODE_BUILDER_NAME,
+ ROOT_NODE_FACTORY_METHOD_NAME,
)
}
} else {
beginControlFlow(
"path.%M(%L(%T(%S), rootSegmentAlias)) ->",
MemberName(LIBRARY_PACKAGE, "startsWith"),
- GET_TARGET_FUN_NAME,
+ TARGET_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
)
addStatement(
"val targetPath = %L(%T(%S), rootSegmentAlias)",
- GET_TARGET_FUN_NAME,
+ TARGET_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
)
@@ -326,9 +430,15 @@ private fun createBuildFunctionBody(
lazyNodeBuilderFactories[node] ?: error("no lazy builder property for \"${node.id}\""),
)
}
+ // The `.filterKeys { it.length > targetPath.length - 1 }` before mapKeys is a drop
+ // safety guard: `Path.drop(n)` on a Path with `n` segments would yield an empty
+ // segment list and fail Path's `isNotEmpty` init check. Filter-first removes the
+ // payloads keys that can't survive the drop — by definition those keys refer to
+ // ancestors above the current cascade level, which the inner build no longer needs.
addStatement(
"nodeBuilder.build(path.%M(targetPath.length·-·1)," +
- " payloads·=·payloads.mapKeys·{·it.key.%M(targetPath.length·-·1)·}," +
+ " payloads·=·payloads.filterKeys·{·it.length·>·targetPath.length·-·1·}" +
+ ".mapKeys·{·it.key.%M(targetPath.length·-·1)·}," +
" rootSegmentAlias·=·targetPath.%M())",
MemberName(LIBRARY_PACKAGE, "drop"),
MemberName(LIBRARY_PACKAGE, "drop"),
@@ -342,19 +452,19 @@ private fun createBuildFunctionBody(
if (node.parameter != null) {
addStatement(
"path == %L(%T(%S), rootSegmentAlias) -> %L.%N(%L(%T(%S), payloads, rootSegmentAlias))",
- GET_TARGET_FUN_NAME,
+ TARGET_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
NODE_FACTORY_PARAMETER_NAME,
nodeBuilders[node] ?: error("no builder for screen node \"${node.id}\""),
- GET_PAYLOAD_FUN_NAME,
+ PAYLOAD_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
)
} else {
addStatement(
"path == %L(%T(%S), rootSegmentAlias) -> %L.%N()",
- GET_TARGET_FUN_NAME,
+ TARGET_OR_ERROR_FUN_NAME,
SEGMENT,
buildSegmentId(node),
NODE_FACTORY_PARAMETER_NAME,
@@ -362,7 +472,11 @@ private fun createBuildFunctionBody(
)
}
}
+
+ // History nodes are never built, so they emit no routing branch.
+ is Node.History -> Unit
}
+ shouldDescend
}
addStatement("else -> error(%P)", "illegal path build requested for \"${flow.id}\" node: \$path")
}
@@ -370,19 +484,19 @@ private fun createBuildFunctionBody(
.build()
}
-private fun buildGetTargetFunSpec(): FunSpec = FunSpec.builder(GET_TARGET_FUN_NAME)
+private fun buildTargetOrErrorFunSpec(): FunSpec = FunSpec.builder(TARGET_OR_ERROR_FUN_NAME)
.returns(PATH)
.addParameter("segment", SEGMENT)
.addParameter("rootSegmentAlias", SEGMENT.copy(nullable = true))
.addCode(
- "return %L.target(%L.regions.first(),${NBSP}segment, rootSegmentAlias) ?: error(%P)",
+ "return %L.regions.firstNotNullOfOrNull·{·%L.target(it,${NBSP}segment, rootSegmentAlias)·}·?: error(%P)",
SCHEMA_PARAMETER_NAME,
SCHEMA_PARAMETER_NAME,
"internal error: no target generated for segment \"\${segment.id}\"",
)
.build()
-private fun buildGetPayloadBySegmentIdFunSpec(): FunSpec = FunSpec.builder(GET_PAYLOAD_FUN_NAME)
+private fun buildPayloadOrErrorFunSpec(): FunSpec = FunSpec.builder(PAYLOAD_OR_ERROR_FUN_NAME)
.addTypeVariable(TypeVariableName("T"))
.returns(TypeVariableName("T"))
.addAnnotation(
@@ -395,7 +509,7 @@ private fun buildGetPayloadBySegmentIdFunSpec(): FunSpec = FunSpec.builder(GET_P
.addParameter("rootSegmentAlias", SEGMENT.copy(nullable = true))
.addCode(
CodeBlock.builder()
- .addStatement("val targetPath = $GET_TARGET_FUN_NAME(segment, rootSegmentAlias)")
+ .addStatement("val targetPath = $TARGET_OR_ERROR_FUN_NAME(segment, rootSegmentAlias)")
.addStatement(
"val payload = payloads[targetPath] ?: error(%P)",
"no payload for \"\$targetPath\"",
@@ -405,11 +519,37 @@ private fun buildGetPayloadBySegmentIdFunSpec(): FunSpec = FunSpec.builder(GET_P
)
.build()
-private const val NODE_FACTORY_FLOW_NODE_BUILDER_NAME = "createRootNode"
+// Direct path-keyed payload lookup. Used for the root branch of NodeBuilder.build, where
+// rootPath is already known and looking up via `targetOrError(rootSegment)` would walk
+// `schema.regions` searching for the root — which fails for parallel-flow roots (whose own
+// segment is the parent of all regions, not a member of any). NavigationService.start()
+// places the root payload at rootPath directly (NavigationService.kt:166-175), so we
+// retrieve it from there.
+private fun buildRootPayloadOrErrorFunSpec(): FunSpec = FunSpec.builder(ROOT_PAYLOAD_OR_ERROR_FUN_NAME)
+ .addTypeVariable(TypeVariableName("T"))
+ .returns(TypeVariableName("T"))
+ .addAnnotation(
+ AnnotationSpec.builder(Suppress::class)
+ .addMember("%S", "UNCHECKED_CAST")
+ .build(),
+ )
+ .addParameter("path", PATH)
+ .addParameter("payloads", MAP.parameterizedBy(PATH, ANY))
+ .addCode(
+ "return (payloads[path] ?: error(%P)) as T",
+ "no payload for \"\$path\"",
+ )
+ .build()
+
+// These constants hold the NAMES of functions/properties emitted into the generated NodeBuilder;
+// the string values are part of the generated code and must not change.
+private const val ROOT_NODE_FACTORY_METHOD_NAME = "createRootNode"
private const val NODE_FACTORY_PARAMETER_NAME = "nodeFactory"
private const val SCHEMA_PARAMETER_NAME = "schema"
-private const val GET_TARGET_FUN_NAME = "targetOrError"
-private const val GET_PAYLOAD_FUN_NAME = "payloadOrError"
+private const val NODE_BUILDER_CACHE_PROPERTY_NAME = "nodeBuilders"
+private const val TARGET_OR_ERROR_FUN_NAME = "targetOrError"
+private const val PAYLOAD_OR_ERROR_FUN_NAME = "payloadOrError"
+private const val ROOT_PAYLOAD_OR_ERROR_FUN_NAME = "payloadAtPathOrError"
// NOTE_GROUPING_NODES_BY_FLOW_RULE
//
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 2eee486..827e77d 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
@@ -9,14 +9,15 @@ import java.io.File
import java.nio.file.Path
import kotlin.io.path.relativeTo
-internal fun parseSchemaDotFile(file: File, projectDir: File): SchemaParseResult = file.inputStream().use { input ->
- val stream = CommonTokenStream(DotLexer(CharStreams.fromStream(input)))
- val parser = DotParser(stream)
- val parseTree = parser.graph()
- val visitor = Visitor()
- visitor.visitGraph(parseTree)
- visitor.buildResult(file.toPath().relativeTo(projectDir.toPath()))
-}
+internal fun parseSchemaDotFile(file: File, projectDir: File, warn: (String) -> Unit = {}): SchemaParseResult =
+ file.inputStream().use { input ->
+ val stream = CommonTokenStream(DotLexer(CharStreams.fromStream(input)))
+ val parser = DotParser(stream)
+ val parseTree = parser.graph()
+ val visitor = Visitor()
+ visitor.visitGraph(parseTree)
+ visitor.buildResult(file.toPath().relativeTo(projectDir.toPath()), warn)
+ }
private class Visitor : DotBaseVisitor() {
private var graphId: String? = null
@@ -28,10 +29,14 @@ private class Visitor : DotBaseVisitor() {
private val flowNodes: MutableList = mutableListOf()
private val parallelNodes: MutableList = mutableListOf()
private val schemaNodes: MutableList = mutableListOf()
+
+ // History pseudostate nodes: id -> deep flag (`type=deepHistory` => true, `type=history` => false).
+ private val historyNodes: MutableMap = mutableMapOf()
private val flowNodeResultTypes: MutableMap = mutableMapOf()
private val nodeParameters: MutableMap = mutableMapOf()
+ private val declaredNodeTypes: MutableMap = mutableMapOf()
- fun buildResult(filePath: Path): SchemaParseResult {
+ fun buildResult(filePath: Path, warn: (String) -> Unit = {}): SchemaParseResult {
fun String.toNode(): Node = when {
flowNodes.contains(this) -> {
Node.Flow.Local(this, flowNodeResultTypes[this] ?: UNIT.canonicalName, nodeParameters[this])
@@ -45,13 +50,26 @@ private class Visitor : DotBaseVisitor() {
Node.Flow.LocalParallel(this, flowNodeResultTypes[this] ?: UNIT.canonicalName, nodeParameters[this])
}
+ historyNodes.contains(this) -> {
+ Node.History(this, deep = historyNodes.getValue(this))
+ }
+
else -> Node.Screen(this, nodeParameters[this])
}
+ val builtAdjacencyList = this.adjacencyList.entries.associate { (nodeId, adjacentIds) ->
+ nodeId.toNode() to adjacentIds.map { it.toNode() }
+ }
+ validateSchema(builtAdjacencyList, warn)
+ if (graphId != null && !validKotlinIdentifier.matches(graphId!!)) {
+ error(
+ "invalid graph id \"$graphId\": graph ids are used as Kotlin class name prefixes and must be valid " +
+ "Kotlin identifiers (start with a letter or underscore, contain only letters, digits, or underscores). " +
+ "Quoted DOT graph names with spaces, hyphens, or leading digits are not supported.",
+ )
+ }
return SchemaParseResult(
filePath = filePath,
- adjacencyList = this.adjacencyList.entries.associate { (nodeId, adjacentIds) ->
- nodeId.toNode() to adjacentIds.map { it.toNode() }
- },
+ adjacencyList = builtAdjacencyList,
graphId = graphId,
customSchemaFileName = customSchemaFileName,
customTargetsFileName = customTargetsFileName,
@@ -68,9 +86,11 @@ private class Visitor : DotBaseVisitor() {
}
private fun findGraphAttributeValue(ctx: GraphContext, name: String): String? {
- for (i in 0 until ctx.stmt_list().childCount) {
- if (ctx.stmt_list().stmt(i).id_(0)?.asString() == name) {
- return ctx.stmt_list().stmt(i).id_(1)?.asString() ?: error("no value for graph attr '$name'")
+ for (stmt in ctx.stmt_list().stmt()) {
+ // 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) {
+ return stmt.id_(1)?.asString() ?: error("no value for graph attr '$name'")
}
}
return null
@@ -79,71 +99,136 @@ private class Visitor : DotBaseVisitor() {
override fun visitNode_stmt(ctx: DotParser.Node_stmtContext) {
super.visitNode_stmt(ctx)
val nodeId = ctx.node_id()?.id_()?.asString() ?: error("no node id for ${ctx.text}")
- val attrs = ctx.attr_list()?.a_list(0)?.id_()?.chunked(2).orEmpty()
- val isFlowNode = attrs
- .any { (id, value) -> id.asString() == ATTR_NAME_NODE_TYPE && value.asString() == ATTR_VALUE_NODE_TYPE_FLOW }
- val isSchemaNode = attrs
- .any { (id, value) -> id.asString() == ATTR_NAME_NODE_TYPE && value.asString() == ATTR_VALUE_NODE_TYPE_SCHEMA }
- val isParallelNode = attrs
- .any { (id, value) -> id.asString() == ATTR_NAME_NODE_TYPE && value.asString() == ATTR_VALUE_NODE_TYPE_PARALLEL }
+ val attrs = ctx.attr_list()?.a_list()?.flatMap { parseAttrPairs(it) }.orEmpty()
+
+ // `type=X` present? / value of attribute `name`, if any.
+ fun hasNodeType(typeValue: String): Boolean =
+ attrs.any { (id, value) -> id.asString() == ATTR_NAME_NODE_TYPE && value.asString() == typeValue }
+ fun attrValue(name: String): String? = attrs.find { (id, _) -> id.asString() == name }?.get(1)?.asString()
+
+ val isFlowNode = hasNodeType(ATTR_VALUE_NODE_TYPE_FLOW)
+ val isSchemaNode = hasNodeType(ATTR_VALUE_NODE_TYPE_SCHEMA)
+ val isParallelFlowNode = hasNodeType(ATTR_VALUE_NODE_TYPE_PARALLEL_FLOW)
+ val isHistoryNode = hasNodeType(ATTR_VALUE_NODE_TYPE_HISTORY)
+ val isDeepHistoryNode = hasNodeType(ATTR_VALUE_NODE_TYPE_DEEP_HISTORY)
+ val newType = when {
+ isFlowNode -> "flow"
+ isSchemaNode -> "schema"
+ isParallelFlowNode -> "parallelFlow"
+ isHistoryNode -> "history"
+ isDeepHistoryNode -> "deepHistory"
+ else -> null
+ }
+ recordNodeTypeDeclaration(nodeId, newType)
+ // Note the deliberate asymmetry below: the parallel branch defaults an omitted resultType to
+ // kotlin.Unit, while the flow/schema branches leave it unset (buildResult supplies the Unit
+ // default when reading). Do not "unify" these — it would change the recorded result types.
if (isFlowNode) {
adjacencyList.getOrPut(nodeId) { mutableSetOf() }
flowNodes.add(nodeId)
- val resultType = attrs
- .find { (id, _) -> id.asString() == ATTR_NAME_FLOW_RESULT_TYPE }
- ?.get(1)
- ?.asString()
- if (resultType != null) {
- flowNodeResultTypes[nodeId] = resultType
- }
+ attrValue(ATTR_NAME_FLOW_RESULT_TYPE)?.let { flowNodeResultTypes[nodeId] = it }
} else if (isSchemaNode) {
adjacencyList.getOrPut(nodeId) { mutableSetOf() }
schemaNodes.add(nodeId)
- val resultType = attrs
- .find { (id, _) -> id.asString() == ATTR_NAME_FLOW_RESULT_TYPE }
- ?.get(1)
- ?.asString()
- if (resultType != null) {
- flowNodeResultTypes[nodeId] = resultType
- }
- } else if (isParallelNode) {
+ attrValue(ATTR_NAME_FLOW_RESULT_TYPE)?.let { flowNodeResultTypes[nodeId] = it }
+ } else if (isParallelFlowNode) {
adjacencyList.getOrPut(nodeId) { mutableSetOf() }
parallelNodes.add(nodeId)
+ // Capture optional resultType for the parallel-flow itself. Defaults to kotlin.Unit when
+ // omitted (the parallel-flow rarely cares about its own typed result).
+ flowNodeResultTypes[nodeId] = attrValue(ATTR_NAME_FLOW_RESULT_TYPE) ?: UNIT.canonicalName
+ } else if (isHistoryNode || isDeepHistoryNode) {
+ // History pseudostate leaf: it is reached only through its parent flow's edge (which does the
+ // `getOrPut`), never has children of its own, and is NEVER built at runtime — it exists purely
+ // so the codegen can emit a typed `HistoryTarget` accessor pointing at the parent flow.
+ historyNodes[nodeId] = isDeepHistoryNode
}
- val parameterType = attrs
- .find { (id, _) -> id.asString() == ATTR_NAME_PARAMETER_TYPE }
- ?.get(1)
- ?.asString()
- val parameterName = attrs
- .find { (id, _) -> id.asString() == ATTR_NAME_PARAMETER_NAME }
- ?.get(1)
- ?.asString()
+ val parameterType = attrValue(ATTR_NAME_PARAMETER_TYPE)
+ val parameterName = attrValue(ATTR_NAME_PARAMETER_NAME)
+ if ((parameterName == null) != (parameterType == null)) {
+ error("node \"$nodeId\": parameterName and parameterType must both be specified or both omitted")
+ }
if (parameterName != null && parameterType != null) {
nodeParameters[nodeId] = Parameter(name = parameterName, type = parameterType)
}
}
+ /**
+ * Pairs each attribute id with its value id within one `a_list`. Tolerates a malformed `name "value"`
+ * attribute with NO `=` sign by pairing the two adjacent ids positionally — real consumer graphs
+ * contain this (prsv main_flow.dot's `parameterName "categoryType"`), and master's positional
+ * `chunked(2)` parser accepted it. The stricter `=` form is still preferred and used everywhere else.
+ */
+ private fun parseAttrPairs(aList: DotParser.A_listContext): List> {
+ val pairs = mutableListOf>()
+ val children = aList.children ?: return pairs
+ var i = 0
+ while (i < children.size) {
+ val child = children[i]
+ if (child is Id_Context) {
+ val next = children.getOrNull(i + 1)
+ val valueChild = children.getOrNull(i + 2)
+ if (next?.text == "=" && valueChild is Id_Context) {
+ pairs.add(listOf(child, valueChild))
+ i += 3
+ } else if (next is Id_Context) {
+ pairs.add(listOf(child, next))
+ i += 2
+ } else {
+ i++ // skip genuinely value-less attribute
+ }
+ } else {
+ i++ // skip ',' and '=' terminals
+ }
+ }
+ return pairs
+ }
+
+ /** Records [nodeId]'s declared [newType] (no-op when untyped), erroring on a conflicting or duplicate declaration. */
+ private fun recordNodeTypeDeclaration(nodeId: String, newType: String?) {
+ if (newType == null) return
+ val prev = declaredNodeTypes[nodeId]
+ if (prev != null) {
+ val explanation = if (prev == newType) {
+ "redeclared as \"$newType\". Later declarations silently overwrite parameter and " +
+ "result-type attributes; remove the duplicate."
+ } else {
+ "is declared as both \"$prev\" and \"$newType\". Each node id may have at most one type attribute."
+ }
+ error("duplicate node definition: \"$nodeId\" $explanation")
+ }
+ declaredNodeTypes[nodeId] = newType
+ }
+
override fun visitEdge_stmt(ctx: DotParser.Edge_stmtContext) {
val nodeId = ctx.node_id().id_().asString()
super.visitEdge_stmt(ctx)
val rhsFirstNode = ctx.edgeRHS().node_id(0).id_().asString()
- adjacencyList.getOrPut(nodeId) { mutableSetOf() }
- adjacencyList.getOrPut(rhsFirstNode) { mutableSetOf() }
- adjacencyList[nodeId]?.add(rhsFirstNode)
+ addEdge(from = nodeId, to = rhsFirstNode)
}
override fun visitEdgeRHS(ctx: DotParser.EdgeRHSContext) {
- val nodeIds = ctx.node_id()
- nodeIds.windowed(2).forEach { (id1, id2) ->
- adjacencyList.getOrPut(id1.id_().asString()) { mutableSetOf() }
- adjacencyList.getOrPut(id2.id_().asString()) { mutableSetOf() }
- adjacencyList[id1.id_().asString()]?.add(id2.text)
+ ctx.node_id().windowed(2).forEach { (id1, id2) ->
+ addEdge(from = id1.id_().asString(), to = id2.id_().asString())
}
super.visitEdgeRHS(ctx)
}
- private fun Id_Context.asString(): String = this.ID()?.text ?: this.STRING().text.removeSurrounding("\"")
+ /** Ensures both endpoints exist in the adjacency list and records the directed edge [from] → [to]. */
+ private fun addEdge(from: String, to: String) {
+ adjacencyList.getOrPut(from) { mutableSetOf() }
+ adjacencyList.getOrPut(to) { mutableSetOf() }
+ adjacencyList[from]?.add(to)
+ }
+
+ private fun Id_Context.asString(): String = when {
+ ID() != null -> ID()!!.text
+ STRING() != null -> STRING()!!.text.removeSurrounding("\"")
+ HTML_STRING() != null -> HTML_STRING()!!.text.removeSurrounding("<", ">")
+ NUMBER() != null -> NUMBER()!!.text
+ else -> error("unrecognized id_ token: ${this.text}")
+ }
}
internal data class SchemaParseResult(
@@ -153,6 +238,7 @@ internal data class SchemaParseResult(
val filePath: Path,
val graphId: String?,
val customSchemaFileName: String?,
+ val customSchemaClassName: String? = null,
val customTargetsFileName: String?,
val customPackage: String?,
val adjacencyList: AdjacencyList,
@@ -178,14 +264,221 @@ internal sealed interface Node {
Flow
}
data class Screen(override val id: String, val parameter: Parameter?) : Node
+
+ /**
+ * An SCXML history pseudostate leaf declared under exactly one flow via `parentFlow -> historyNode`
+ * with `type="history"` (shallow) or `type="deepHistory"` ([deep] = true).
+ *
+ * A history node is codegen-time-only: it never gets a runtime NodeType, node factory, adjacency,
+ * or finish-event entry. Its sole product is a typed [HISTORY_TARGET] accessor whose path points at
+ * the PARENT FLOW (the flow whose most-recently-active configuration to restore), not at the history
+ * node itself.
+ */
+ data class History(override val id: String, val deep: Boolean) : Node
}
internal data class Parameter(val name: String, val type: String)
+private fun validateSchema(adjacencyList: AdjacencyList, warn: (String) -> Unit = {}) {
+ validateNodeIds(adjacencyList)
+ validateNoEmptyFlows(adjacencyList)
+ validateNoCycles(adjacencyList)
+ validateNoFanIn(adjacencyList)
+ validateNoDisconnectedSubgraphs(adjacencyList)
+ validateHistoryNodes(adjacencyList)
+ warnSiblingScreenAndSchema(adjacencyList, warn)
+}
+
+private fun validateHistoryNodes(adjacencyList: AdjacencyList) {
+ for ((node, children) in adjacencyList) {
+ if (node !is Node.History) continue
+ if (children.isNotEmpty()) {
+ error(
+ "history node \"${node.id}\" must be a childless leaf, but has children: " +
+ "${children.joinToString { "\"${it.id}\"" }}. A history pseudostate cannot have outgoing edges.",
+ )
+ }
+ val parent = adjacencyList.findParent(node)
+ ?: error(
+ "history node \"${node.id}\" has no parent flow. Declare it under exactly one flow via an edge, " +
+ "e.g. `parentFlow -> ${node.id}`.",
+ )
+ when (parent) {
+ is Node.Flow.Local -> Unit
+
+ // history under a plain flow hosts its accessor in that flow's Targets
+ is Node.Flow.LocalParallel ->
+ // A parallel-parented history is valid, but its accessor is emitted into the nearest ENCLOSING
+ // plain flow's Targets class. A root/top parallel with no enclosing plain flow has nowhere to host it.
+ if (adjacencyList.findAllParents(parent, includeThis = false).none { it is Node.Flow.Local }) {
+ error(
+ "history node \"${node.id}\" is under a root parallel \"${parent.id}\" with no enclosing flow to " +
+ "host its accessor; nest the parallel under a flow.",
+ )
+ }
+
+ else ->
+ error(
+ "history node \"${node.id}\" must be a direct child of a plain flow (type=flow) or a parallel flow, " +
+ "but its parent \"${parent.id}\" is a ${parent::class.simpleName}. History under IMPORTED flows is " +
+ "not supported.",
+ )
+ }
+ }
+}
+
+private val validKotlinIdentifier = Regex("^[a-zA-Z_][a-zA-Z0-9_]*$")
+
+private fun validateNodeIds(adjacencyList: AdjacencyList) {
+ for (node in adjacencyList.keys) {
+ if (!validKotlinIdentifier.matches(node.id)) {
+ error(
+ "invalid node id \"${node.id}\": node ids must be valid Kotlin identifiers " +
+ "(start with a letter or underscore, contain only letters, digits, or underscores). " +
+ "Quoted DOT ids with spaces, hyphens, or leading digits are not supported.",
+ )
+ }
+ }
+}
+
+private fun validateNoEmptyFlows(adjacencyList: AdjacencyList) {
+ // LocalParallel nodes must always have children regardless of depth — an empty parallel has
+ // no regions at runtime and causes a crash, not a graceful error.
+ // For Local flows: only flag root nodes (no parent) with no children; non-root childless
+ // Local flows are valid (imported sub-schemas or parallel children declared elsewhere).
+ // Imported (schema) flows are excluded — they always appear childless here by design.
+ val nodesWithIncoming = adjacencyList.values.flatten().toSet()
+ for ((node, children) in adjacencyList) {
+ if (node is Node.Flow.LocalParallel && children.isEmpty()) {
+ error("parallel node \"${node.id}\" has no children; a parallel node must have at least one child flow")
+ }
+ if (node is Node.Flow.Local && node !in nodesWithIncoming && children.isEmpty()) {
+ error("flow node \"${node.id}\" has no children; a flow must have at least one child screen or flow")
+ }
+ }
+}
+
+private fun validateNoFanIn(adjacencyList: AdjacencyList) {
+ // Imported schemas (`type=schema`) are intentionally reusable across entry points: the
+ // runtime resolves which entry was taken via AbsoluteTarget paths. Only LOCAL nodes
+ // (flows, parallels, screens) need a unique parent for backstack ordering.
+ val incomingCount = mutableMapOf()
+ adjacencyList.values.flatten().forEach { node ->
+ if (node is Node.Flow.Imported) return@forEach
+ incomingCount[node] = (incomingCount[node] ?: 0) + 1
+ }
+ val fanInNodes = incomingCount.filter { it.value > 1 }.keys
+ if (fanInNodes.isNotEmpty()) {
+ error(
+ "invalid schema: the following local nodes have multiple incoming edges (fan-in): " +
+ "${fanInNodes.joinToString { "\"${it.id}\"" }}. " +
+ "Each local node must have exactly one parent. Restructure the graph to eliminate shared " +
+ "nodes (note: `type=schema` imported nodes are exempt from this check).",
+ )
+ }
+}
+
+private fun validateNoCycles(adjacencyList: AdjacencyList) {
+ // DFS-based cycle detection: track the current recursion stack separately from globally visited nodes.
+ val globalVisited = HashSet(adjacencyList.size)
+
+ fun dfsCheck(node: Node, currentPath: List) {
+ globalVisited.add(node)
+ for (neighbour in adjacencyList[node].orEmpty()) {
+ if (currentPath.contains(neighbour)) {
+ val cycle = (currentPath + neighbour).joinToString(" -> ") { it.id }
+ error("cycle detected in schema: node \"${neighbour.id}\" is reachable from itself via path: $cycle")
+ }
+ if (!globalVisited.contains(neighbour)) {
+ dfsCheck(neighbour, currentPath + neighbour)
+ }
+ }
+ }
+
+ // Find root nodes (nodes with no incoming edges) and start DFS from each.
+ val nodesWithIncoming = adjacencyList.values.flatten().toSet()
+ val roots = adjacencyList.keys.filter { it !in nodesWithIncoming }
+ // If no roots found (fully cyclic graph), start from every node.
+ val startNodes = if (roots.isEmpty()) adjacencyList.keys.toList() else roots
+ for (root in startNodes) {
+ if (!globalVisited.contains(root)) {
+ dfsCheck(root, listOf(root))
+ }
+ }
+ // Also catch cycles in components disconnected from any root.
+ for (node in adjacencyList.keys) {
+ if (!globalVisited.contains(node)) {
+ dfsCheck(node, listOf(node))
+ }
+ }
+}
+
+private fun validateNoDisconnectedSubgraphs(adjacencyList: AdjacencyList) {
+ // DFS from flow-type roots only. Non-flow nodes (screens, imported schemas) with no incoming
+ // edges are orphaned by definition — they should never be treated as valid graph roots.
+ val nodesWithIncoming = adjacencyList.values.flatten().toSet()
+ val flowRoots = adjacencyList.keys.filter { it is Node.Flow && it !in nodesWithIncoming }
+ if (flowRoots.size > 1) {
+ error(
+ "schema has multiple root flows: ${flowRoots.joinToString { "\"${it.id}\"" }}. " +
+ "A schema must have exactly one root flow.",
+ )
+ }
+ val reachable = HashSet(adjacencyList.size)
+ val startNodes = if (flowRoots.isEmpty()) adjacencyList.keys.toList() else flowRoots
+ for (root in startNodes) {
+ dfs(adjacencyList, root) { reachable.add(it) }
+ }
+ val unreachable = adjacencyList.keys.filter { it !in reachable }
+ if (unreachable.isNotEmpty()) {
+ error(
+ "disconnected subgraph detected: the following nodes are not reachable from any root and will " +
+ "never be navigated to: ${unreachable.joinToString { "\"${it.id}\"" }}. " +
+ "Connect them to the graph or remove them.",
+ )
+ }
+}
+
+/**
+ * Warns when a [Node.Flow.Local] has both screen nodes and imported-schema (child flow) nodes as
+ * direct children — that is, they share the same parent in the DOT graph.
+ *
+ * This pattern is valid but frequently accidental: navigating to the schema from any screen in the
+ * flow will dismiss that screen from the alive stack (alive stack becomes [flow, schema] instead of
+ * [flow, screen, schema]). If the intent is to keep a screen alive while entering the child flow,
+ * the DOT edge should go from the screen to the schema, not from the flow to the schema.
+ *
+ * Example of the suspicious pattern:
+ * mainFlow -> main // screen — sibling of chatFlow
+ * mainFlow -> chatFlow // schema — main is dismissed when navigating here
+ *
+ * Example of the corrected pattern:
+ * mainFlow -> main // screen
+ * main -> chatFlow // schema — main stays alive in the alive stack
+ */
+private fun warnSiblingScreenAndSchema(adjacencyList: AdjacencyList, warn: (String) -> Unit) {
+ adjacencyList.forEachFlow { flow, children ->
+ val screenSiblings = children.filterIsInstance()
+ val schemaSiblings = children.filterIsInstance()
+ if (screenSiblings.isEmpty() || schemaSiblings.isEmpty()) return@forEachFlow
+ val screens = screenSiblings.joinToString { "\"${it.id}\"" }
+ val schemas = schemaSiblings.joinToString { "\"${it.id}\"" }
+ warn(
+ "[way] \"${flow.id}\" has screen(s) $screens and child-flow schema(s) $schemas as direct siblings. " +
+ "Navigating to $schemas from a screen in \"${flow.id}\" will dismiss that screen — " +
+ "alive stack becomes [${flow.id}, schema], not [${flow.id}, screen, schema]. " +
+ "If you want the screen to remain alive while inside the schema, change the edge: " +
+ "\"${flow.id} -> schema\" → \"screen -> schema\".",
+ )
+ }
+}
+
private const val ATTR_NAME_NODE_TYPE = "type"
private const val ATTR_VALUE_NODE_TYPE_FLOW = "flow"
private const val ATTR_VALUE_NODE_TYPE_SCHEMA = "schema"
-private const val ATTR_VALUE_NODE_TYPE_PARALLEL = "parallel"
+private const val ATTR_VALUE_NODE_TYPE_PARALLEL_FLOW = "parallelFlow"
+private const val ATTR_VALUE_NODE_TYPE_HISTORY = "history"
+private const val ATTR_VALUE_NODE_TYPE_DEEP_HISTORY = "deepHistory"
private const val ATTR_NAME_FLOW_RESULT_TYPE = "resultType"
private const val ATTR_NAME_PARAMETER_NAME = "parameterName"
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/RegionEnumCodegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/RegionEnumCodegen.kt
new file mode 100644
index 0000000..a1fd675
--- /dev/null
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/RegionEnumCodegen.kt
@@ -0,0 +1,80 @@
+package ru.kode.way.gradle
+
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeSpec
+
+/**
+ * Emits an `enum class Region(val segmentName: String)` alongside the schema
+ * for parallel-rooted top-level schemas (schemas with more than one direct region root).
+ *
+ * The generated enum gives consumers a stable, typed identifier per sub-region without forcing
+ * them to compare [ru.kode.way.RegionId] values directly. Each entry carries the segment name
+ * (the portion before `@file.dot`), and a companion `forRegionId` performs the reverse lookup.
+ *
+ * Returns null when the schema has zero or one region (flow-rooted schemas) — there is no enum
+ * worth emitting in that case.
+ */
+internal fun buildRegionEnumFileSpecOrNull(parseResult: SchemaParseResult, config: CodeGenConfig): FileSpec? {
+ val regionRoots = buildRegionRoots(parseResult.adjacencyList)
+ if (regionRoots.size <= 1) return null
+
+ val packageName = parseResult.customPackage ?: config.outputPackageName
+ val schemaClassName = schemaClassName(parseResult, config)
+ val enumSimpleName = regionEnumClassName(schemaClassName)
+ val enumClassName = ClassName(packageName, enumSimpleName)
+
+ val enumBuilder = TypeSpec.enumBuilder(enumClassName)
+ .primaryConstructor(
+ FunSpec.constructorBuilder()
+ .addParameter(ParameterSpec.builder("segmentName", String::class).build())
+ .build(),
+ )
+ .addProperty(
+ PropertySpec.builder("segmentName", String::class)
+ .initializer("segmentName")
+ .build(),
+ )
+
+ regionRoots.forEach { regionRoot ->
+ enumBuilder.addEnumConstant(
+ regionRoot.id.toPascalCase(),
+ TypeSpec.anonymousClassBuilder()
+ .addSuperclassConstructorParameter("%S", regionRoot.id)
+ .build(),
+ )
+ }
+
+ // companion object { fun forRegionId(regionId: RegionId): ? = ... }
+ enumBuilder.addType(
+ TypeSpec.companionObjectBuilder()
+ .addFunction(
+ FunSpec.builder("forRegionId")
+ .addParameter("regionId", REGION_ID)
+ .returns(enumClassName.copy(nullable = true))
+ .addStatement(
+ // Emits `substringBefore('@')`; the delimiter is sourced from the same constant that
+ // buildSegmentId uses to construct the id, keeping the two ends in lockstep.
+ "val name = regionId.path.segments.last().id.substringBefore('$SEGMENT_ID_GRAPH_DELIMITER')",
+ )
+ .addStatement(
+ "return entries.firstOrNull { it.segmentName == name }",
+ )
+ .build(),
+ )
+ .build(),
+ )
+
+ return FileSpec.builder(packageName, enumSimpleName)
+ .addType(enumBuilder.build())
+ .build()
+}
+
+internal fun regionEnumClassName(schemaClassName: String): String {
+ val base = schemaClassName.removeSuffix("Schema")
+ return "${base}Region"
+}
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/SchemaCodegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/SchemaCodegen.kt
index 9d27ccc..c1c61d2 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/SchemaCodegen.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/SchemaCodegen.kt
@@ -14,12 +14,16 @@ import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.UNIT
-internal fun buildSchemaFileSpec(parseResult: SchemaParseResult, config: CodeGenConfig): FileSpec {
+internal fun buildSchemaFileSpec(
+ parseResult: SchemaParseResult,
+ config: CodeGenConfig,
+ registry: SchemaRegistry,
+): FileSpec {
val schemaClassName = schemaClassName(parseResult, config)
val packageName = parseResult.customPackage ?: config.outputPackageName
val schemaFileSpec = FileSpec.builder(
packageName,
- parseResult.customSchemaFileName ?: schemaClassName,
+ schemaFileName(parseResult, config),
)
val regionRoots = buildRegionRoots(parseResult.adjacencyList)
val constructorParameters = buildConstructorParameters(parseResult.adjacencyList)
@@ -29,7 +33,7 @@ internal fun buildSchemaFileSpec(parseResult: SchemaParseResult, config: CodeGen
.build()
}
- fun buildSegmentId(node: Node): String = buildSegmentId(parseResult.filePath, node)
+ fun buildSegmentId(node: Node): String = buildSegmentId(node, parseResult, registry)
val schemaTypeSpec = TypeSpec.classBuilder(name = schemaClassName)
.apply {
@@ -48,6 +52,17 @@ internal fun buildSchemaFileSpec(parseResult: SchemaParseResult, config: CodeGen
.addProperty(
buildRegionsPropertySpec(regionRoots, parseResult.adjacencyList, ::buildSegmentId),
)
+ .apply {
+ // Per-region typed accessors: `public val RegionId: RegionId get() = regions[i]`
+ // Lets callers reference a region by its name without indexing into the list.
+ regionRoots.forEachIndexed { index, regionRoot ->
+ addProperty(
+ PropertySpec.builder("${regionRoot.id}RegionId", REGION_ID)
+ .getter(FunSpec.getterBuilder().addStatement("return regions[$index]").build())
+ .build(),
+ )
+ }
+ }
.addFunction(
buildSchemaTargetsSpec(parseResult.adjacencyList, ::buildSegmentId),
)
@@ -115,66 +130,82 @@ private fun buildChildSchemasProperty(adjacencyList: AdjacencyList, buildSegment
is Node.Flow.Local,
is Node.Flow.LocalParallel,
is Node.Screen,
+ is Node.History,
-> Unit
}
}
- if (importedFlowNodes.isEmpty()) {
- return PropertySpec
- .builder(
- "childSchemas",
- MAP.parameterizedBy(
- SEGMENT,
- SCHEMA,
- ),
- KModifier.OVERRIDE,
- )
- .initializer("emptyMap()")
- .build()
- } else {
- return PropertySpec
- .builder(
- "childSchemas",
- MAP.parameterizedBy(
- SEGMENT,
- SCHEMA,
- ),
- KModifier.OVERRIDE,
- )
- .initializer(
- CodeBlock.builder()
- .add("mapOf(")
- .apply {
- importedFlowNodes.forEachIndexed { index, node ->
- add(
+ // Virtual sub-schema roots — nested LocalParallels and LOCAL flow children of LocalParallel —
+ // also contribute child-schema entries. Runtime helpers like `findParentSchema` and
+ // `maybeResolveInitial` walk `childSchemas` to descend; without these entries a nested
+ // parallel's `regions` lookup falls back to its outer parent's regions and either reports the
+ // wrong sub-regions or recurses forever.
+ //
+ // Each virtual sub-schema is instantiated inline. It inherits the same Imported-schema
+ // constructor properties this outer schema holds — virtual sub-graphs may contain Imported
+ // flows, and we thread those through by forwarding the relevant constructor properties.
+ val virtualRoots = adjacencyList.virtualSubSchemaRoots()
+ val entries = importedFlowNodes.map { it to ChildSchemaSource.Imported } +
+ virtualRoots.map { it to ChildSchemaSource.Virtual }
+ val childSchemasProperty = PropertySpec.builder(
+ "childSchemas",
+ MAP.parameterizedBy(SEGMENT, SCHEMA),
+ KModifier.OVERRIDE,
+ )
+ if (entries.isEmpty()) {
+ return childSchemasProperty.initializer("emptyMap()").build()
+ }
+ return childSchemasProperty
+ .initializer(
+ CodeBlock.builder()
+ .add("mapOf(")
+ .apply {
+ entries.forEachIndexed { index, (node, source) ->
+ when (source) {
+ ChildSchemaSource.Imported -> add(
"%T(%S)·to·%L",
SEGMENT,
buildSegmentId(node),
schemaConstructorPropertyName(node),
)
- if (index != importedFlowNodes.lastIndex) {
- add(", ")
+
+ ChildSchemaSource.Virtual -> {
+ // Compute the Imported flows the virtual sub-schema's constructor expects (the
+ // sub-graph's own Importeds), and pass through this outer schema's matching
+ // properties so the runtime sees a consistent schema tree.
+ //
+ // These args are forwarded POSITIONALLY. That is safe because the virtual schema's
+ // own constructor params come from `buildConstructorParameters(subAdjList)` — i.e.
+ // `subAdjList` map order — and `subAdjList` is the LinkedHashMap that `subgraphFor`
+ // fills in DFS order, so this fresh `dfs(subAdjList, root)` visits Imported flows in
+ // the identical order. Pinned by AdjacencyListTest's "positional forwarding is safe".
+ val subAdjList = adjacencyList.subgraphFor(node)
+ val virtualImportedFlows = mutableListOf()
+ dfs(subAdjList, subAdjList.findRootNode()) { n ->
+ if (n is Node.Flow.Imported) virtualImportedFlows.add(n)
+ }
+ add(
+ "%T(%S)·to·%L(%L)",
+ SEGMENT,
+ buildSegmentId(node),
+ virtualSchemaClassName(node),
+ virtualImportedFlows.joinToString(", ") { schemaConstructorPropertyName(it) },
+ )
}
}
+ if (index != entries.lastIndex) add(", ")
}
- .add(")")
- .build(),
- )
- .build()
- }
+ }
+ .add(")")
+ .build(),
+ )
+ .build()
}
-private fun buildRegionRoots(adjacencyList: AdjacencyList): List {
- val regionRoots = mutableListOf()
- adjacencyList.forEach { (node, children) ->
- if (node is Node.Flow.LocalParallel) {
- regionRoots.addAll(children)
- }
- }
- if (regionRoots.isEmpty()) {
- regionRoots.add(adjacencyList.findRootNode())
- }
- return regionRoots
-}
+private enum class ChildSchemaSource { Imported, Virtual }
+
+// `buildRegionRoots` moved to AdjacencyList.kt (with `internal` visibility) so other codegen
+// files can reuse it. Keeping a duplicate `private` definition here caused overload-resolution
+// ambiguity at the call sites in this file.
private fun buildConstructorParameters(adjacencyList: AdjacencyList): List {
val parameters = mutableListOf()
@@ -189,8 +220,16 @@ private fun buildConstructorParameters(adjacencyList: AdjacencyList): List String): FunSpec =
- FunSpec.builder("target")
+private fun buildSchemaTargetsSpec(adjacencyList: AdjacencyList, buildSegmentId: (Node) -> String): FunSpec {
+ // `rootSegment` is anchored at the schema's own root, NOT the regionRoot.
+ // For parallel-flow-rooted schemas (top-level or virtual sub-schemas for nested parallels),
+ // the schema root is the parallel parent and regionRoots are its sub-region children, so paths
+ // become `Path(rootSegment, regionRoot, ...)`. For regular flow-rooted schemas the schema root
+ // IS the (single) regionRoot, so paths reduce to `Path(rootSegment, ...)` with no extra hop.
+ // This contract matches the documented behavior of `rootSegmentAlias` (NodeBuilder.kt:22-38):
+ // the alias replaces the schema's *rootSegment*, not the regionRoot.
+ val schemaRoot = adjacencyList.findRootNode()
+ return FunSpec.builder("target")
.addModifiers(KModifier.OVERRIDE)
.addParameter("regionId", REGION_ID)
.addParameter("segment", SEGMENT)
@@ -205,27 +244,29 @@ private fun buildSchemaTargetsSpec(adjacencyList: AdjacencyList, buildSegmentId:
addStatement(
"val rootSegment = rootSegmentAlias ?: %T(%S)",
SEGMENT,
- buildSegmentId(regionRoot),
+ buildSegmentId(schemaRoot),
)
beginControlFlow("when(segment.id) {")
+ // Emit one case per node in the schema's full subgraph. Paths are anchored at the
+ // schema's `rootSegment` (see `emitTargetCase`) so every region branch produces the
+ // same absolute paths — the per-region branching only exists to satisfy `regions[i] ->`
+ // pattern matching, the path layout itself is region-independent.
+ //
+ // We deliberately cover the entire subgraph (not just descendants of `regionRoot`) so
+ // that intermediate nodes between the schema root and the region roots — e.g. a
+ // nested LocalParallel sitting between an outer `parallelFlow` schema root and its
+ // tab sub-regions — also have a `target` entry. Without this, `AcmeMainFlowSchema.target`
+ // would not resolve a query for the nested `acmeTabsFlow` segment and the runtime would
+ // crash in `targetOrError` with "no target generated for segment".
+ //
// TODO @AdjacencyMatrix
// not very efficient: running DFS and then for each node inspecting all adjacency list to find parent
// adjacency matrix would allow to find parent nodes more easily.
// This stuff is going on in many places during codegen, search for them if will be optimizing
- dfs(adjacencyList, regionRoot) { node ->
- if (node.id == regionRoot.id) {
- addStatement(
- "rootSegment.id -> %T(rootSegment)",
- PATH,
- )
- } else {
- addStatement(
- "%S -> %T(listOf(rootSegment, %L))",
- buildSegmentId(node),
- PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
- )
- }
+ // History nodes are codegen-time-only and are never built/resolved at runtime; their
+ // HistoryTarget carries the PARENT FLOW path, so no target case is emitted for them.
+ forEachUniqueRuntimeNode(adjacencyList, buildSegmentId) { node ->
+ emitTargetCase(node, adjacencyList, buildSegmentId)
}
addStatement("else -> null")
endControlFlow() // when (segment.name)
@@ -239,9 +280,58 @@ private fun buildSchemaTargetsSpec(adjacencyList: AdjacencyList, buildSegmentId:
.build(),
)
.build()
+}
+
+/**
+ * Runs [emit] for each unique BUILDABLE node reachable from the schema root — skipping History
+ * pseudostates and de-duplicating by segment id — in DFS order. Shared by the `target()` and
+ * `nodeType()` case emitters so both cover the identical node set in the identical order.
+ */
+private fun CodeBlock.Builder.forEachUniqueRuntimeNode(
+ adjacencyList: AdjacencyList,
+ buildSegmentId: (Node) -> String,
+ emit: CodeBlock.Builder.(Node) -> Unit,
+) {
+ val emitted = mutableSetOf()
+ dfs(adjacencyList, adjacencyList.findRootNode()) { node ->
+ if (node !is Node.History && emitted.add(buildSegmentId(node))) {
+ emit(node)
+ }
+ }
+}
+
+/**
+ * Emits a ` -> Path(...)` line for [node] inside a `target()` `when(segment.id)` block.
+ * The emitted Path is anchored at the schema's root (the local `rootSegment` variable). When [node]
+ * IS the schema root, the path collapses to `Path(rootSegment)`; otherwise it is
+ * `Path(listOf(rootSegment, , node))` where the intermediates are the chain from the
+ * schema root down to [node] (excluding the schema root itself).
+ */
+private fun CodeBlock.Builder.emitTargetCase(
+ node: Node,
+ adjacencyList: AdjacencyList,
+ buildSegmentId: (Node) -> String,
+) {
+ val intermediates = descendantChainFromSchemaRoot(node, adjacencyList)
+ if (intermediates.isEmpty()) {
+ addStatement(
+ "%S -> %T(rootSegment)",
+ buildSegmentId(node),
+ PATH,
+ )
+ } else {
+ addStatement(
+ "%S -> %T(listOf(rootSegment, %L))",
+ buildSegmentId(node),
+ PATH,
+ buildSegmentArgumentList(intermediates, buildSegmentId),
+ )
+ }
+}
-private fun buildSchemaNodeTypeSpec(adjacencyList: AdjacencyList, buildSegmentId: (Node) -> String): FunSpec =
- FunSpec.builder("nodeType")
+private fun buildSchemaNodeTypeSpec(adjacencyList: AdjacencyList, buildSegmentId: (Node) -> String): FunSpec {
+ val schemaRoot = adjacencyList.findRootNode()
+ return FunSpec.builder("nodeType")
.addModifiers(KModifier.OVERRIDE)
.addParameter("regionId", REGION_ID)
.addParameter("path", PATH)
@@ -256,46 +346,17 @@ private fun buildSchemaNodeTypeSpec(adjacencyList: AdjacencyList, buildSegmentId
addStatement(
"val rootSegment = rootSegmentAlias ?: %T(%S)",
SEGMENT,
- buildSegmentId(regionRoot),
+ buildSegmentId(schemaRoot),
)
beginControlFlow("when {")
- dfs(adjacencyList, regionRoot) { node ->
- when (node) {
- is Node.Flow.Local, is Node.Flow.Imported -> {
- if (node.id == regionRoot.id) {
- addStatement(
- "path == %T(rootSegment) -> %T.NodeType.Flow",
- PATH,
- SCHEMA,
- )
- } else {
- addStatement(
- "path == %T(listOf(rootSegment, %L)) -> %T.NodeType.Flow",
- PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
- SCHEMA,
- )
- }
- }
-
- is Node.Flow.LocalParallel -> {
- addStatement(
- "path == %T(listOf(rootSegment, %L)) -> %T.NodeType.Parallel",
- PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
- SCHEMA,
- )
- }
-
- is Node.Screen -> {
- addStatement(
- "path == %T(listOf(rootSegment, %L)) -> %T.NodeType.Screen",
- PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
- SCHEMA,
- )
- }
- }
+ // Emit one case per node in the schema's full subgraph, mirroring `target()`. Covers
+ // intermediate parallel nodes between the schema root and region roots so
+ // `checkSchemaValidity` does not hit `else -> error` for paths like ``
+ // or `.`.
+ // History nodes never reach the runtime nodeType machinery (their target carries the
+ // parent flow path), so no nodeType case is emitted for them.
+ forEachUniqueRuntimeNode(adjacencyList, buildSegmentId) { node ->
+ emitNodeTypeCase(node, adjacencyList, buildSegmentId)
}
beginControlFlow("else -> {")
addStatement("error(%P)", "internal error: no nodeType for path=\$path")
@@ -311,6 +372,44 @@ private fun buildSchemaNodeTypeSpec(adjacencyList: AdjacencyList, buildSegmentId
.build(),
)
.build()
+}
+
+/**
+ * Emits a `path == Path(...) -> Schema.NodeType.X` line inside a `nodeType()` `when {}` block.
+ * The path is anchored at the schema's root (the local `rootSegment` variable) using the same
+ * layout rules as [emitTargetCase].
+ */
+private fun CodeBlock.Builder.emitNodeTypeCase(
+ node: Node,
+ adjacencyList: AdjacencyList,
+ buildSegmentId: (Node) -> String,
+) {
+ val nodeTypeName = when (node) {
+ is Node.Flow.Local, is Node.Flow.Imported -> "Flow"
+
+ is Node.Flow.LocalParallel -> "ParallelFlow"
+
+ is Node.Screen -> "Screen"
+
+ // History nodes are filtered out before this point (they have no runtime nodeType).
+ is Node.History -> error("internal error: history node \"${node.id}\" has no runtime nodeType")
+ }
+ val intermediates = descendantChainFromSchemaRoot(node, adjacencyList)
+ if (intermediates.isEmpty()) {
+ addStatement(
+ "path == %T(rootSegment) -> %T.NodeType.$nodeTypeName",
+ PATH,
+ SCHEMA,
+ )
+ } else {
+ addStatement(
+ "path == %T(listOf(rootSegment, %L)) -> %T.NodeType.$nodeTypeName",
+ PATH,
+ buildSegmentArgumentList(intermediates, buildSegmentId),
+ SCHEMA,
+ )
+ }
+}
private fun buildCreateChildFlowFinishEventSpec(
packageName: String,
@@ -329,16 +428,67 @@ private fun buildCreateChildFlowFinishEventSpec(
buildRegionRoots(adjacencyList).forEachIndexed { regionRootIndex, regionRoot ->
beginControlFlow("regions[$regionRootIndex] -> {")
beginControlFlow("when(path) {")
+ // When the regionRoot is itself a Flow child of a LocalParallel, the runtime invokes
+ // this function via `computeSubRegionFinishBuilder` with a SINGLE-segment path —
+ // `Path(Segment(regionRoot.id))` — to bubble the regionRoot's own finish to its
+ // LocalParallel parent. Emit that single-segment case explicitly; without it the
+ // runtime hits the `else -> error(...)` branch and parallel-sub-region finishes crash.
+ val parallelParent = adjacencyList.parallelParentOf(regionRoot)
+ if (parallelParent != null && regionRoot is Node.Flow) {
+ val regionRootResultType = regionRoot.resultType
+ if (regionRootResultType != UNIT.canonicalName) {
+ val resultType = parseTypeName(regionRootResultType)
+ addStatement(
+ "%T(%T(%S)) -> %T(result as %T)",
+ PATH,
+ SEGMENT,
+ buildSegmentId(regionRoot),
+ childFinishRequestEventClassName(
+ packageName = packageName,
+ flowNodeId = parallelParent.id,
+ childFlowNodeId = regionRoot.id,
+ ),
+ resultType,
+ )
+ } else {
+ addStatement(
+ "%T(%T(%S)) -> %T",
+ PATH,
+ SEGMENT,
+ buildSegmentId(regionRoot),
+ childFinishRequestEventClassName(
+ packageName = packageName,
+ flowNodeId = parallelParent.id,
+ childFlowNodeId = regionRoot.id,
+ ),
+ )
+ }
+ }
+ // For regionRoots that own a virtual sub-schema (LOCAL flow children of LocalParallel)
+ // the descendant DFS would reference interfaces that THIS schema doesn't emit — those
+ // interfaces are owned by the virtual sub-schema, and at runtime `findParentSchema`
+ // descends into the virtual sub-schema before reaching `createChildFlowFinishRequestEvent`
+ // for those descendants. Emitting the descendant cases here would create dead code that
+ // also refers to undeclared classes (compile error).
+ val regionRootOwnsVirtualSubSchema = adjacencyList.isLocalChildOfParallel(regionRoot)
+ if (regionRootOwnsVirtualSubSchema) {
+ beginControlFlow("else -> {")
+ addStatement("error(%P)", "internal error: failed to build child finish event for path=\$path")
+ endControlFlow() // else -> {
+ endControlFlow() // when {
+ endControlFlow() // regions[i] -> {
+ return@forEachIndexed
+ }
dfs(adjacencyList, regionRoot) { node ->
when (node) {
is Node.Flow -> {
if (node.id != regionRoot.id) {
if (node.resultType != UNIT.canonicalName) {
- val resultType = ClassName.bestGuess(node.resultType)
+ val resultType = parseTypeName(node.resultType)
addStatement(
"%T(listOf(rootSegment, %L)) -> %T(result as %T)",
PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
+ buildSegmentArgumentList(descendantChainFromSchemaRoot(node, adjacencyList), buildSegmentId),
childFinishRequestEventClassName(
packageName = packageName,
flowNodeId = regionRoot.id,
@@ -350,7 +500,7 @@ private fun buildCreateChildFlowFinishEventSpec(
addStatement(
"%T(listOf(rootSegment, %L)) -> %T",
PATH,
- buildSegmentArgumentList(reversedParents(node, adjacencyList).drop(1), buildSegmentId),
+ buildSegmentArgumentList(descendantChainFromSchemaRoot(node, adjacencyList), buildSegmentId),
childFinishRequestEventClassName(
packageName = packageName,
flowNodeId = regionRoot.id,
@@ -361,7 +511,9 @@ private fun buildCreateChildFlowFinishEventSpec(
}
}
- is Node.Screen -> Unit
+ is Node.Screen,
+ is Node.History,
+ -> Unit
}
}
beginControlFlow("else -> {")
@@ -381,10 +533,27 @@ private fun buildCreateChildFlowFinishEventSpec(
private fun schemaConstructorPropertyName(node: Node) = "${node.id}Schema"
-internal fun schemaClassName(parseResult: SchemaParseResult, config: CodeGenConfig): String = parseResult.graphId?.let {
- "${it}Schema"
-} ?: config.outputSchemaClassName
+internal fun schemaClassName(parseResult: SchemaParseResult, config: CodeGenConfig): String =
+ // `customSchemaClassName` is set for virtual sub-schemas — they share the parent file's
+ // graphId (so their segment ids carry the same `@graphId:filePath` suffix and match across
+ // boundaries) but need a distinct Kotlin class name to avoid colliding with the outer schema.
+ // For regular schemas, fall back to `Schema` and finally to the config default.
+ // Note: `customSchemaFileName` controls only the .kt file name (test fixtures sometimes set it
+ // to a path-style string that is NOT a valid Kotlin identifier), so it must NOT influence the
+ // class name here.
+ parseResult.customSchemaClassName
+ ?: parseResult.graphId?.let { "${it}Schema" }
+ ?: config.outputSchemaClassName
-private fun reversedParents(node: Node, adjacencyList: AdjacencyList): List = adjacencyList
+internal fun reversedParents(node: Node, adjacencyList: AdjacencyList): List = adjacencyList
.findAllParents(node, includeThis = true)
.reversed()
+
+/**
+ * The chain of nodes from the schema root down to [node] EXCLUDING the schema root itself, in
+ * root-to-node order (`reversedParents(node).drop(1)`). These are the intermediate segments used to
+ * anchor a node's absolute path at the generated `rootSegment` in `target()` / `nodeType()` /
+ * finish-event cases.
+ */
+private fun descendantChainFromSchemaRoot(node: Node, adjacencyList: AdjacencyList): List =
+ reversedParents(node, adjacencyList).drop(1)
diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/TargetsCodegen.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/TargetsCodegen.kt
index de4b4bc..d135c75 100644
--- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/TargetsCodegen.kt
+++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/TargetsCodegen.kt
@@ -8,13 +8,16 @@ import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeSpec
-internal fun buildTargetsFileSpec(parseResult: SchemaParseResult, config: CodeGenConfig): FileSpec {
- val targetsFileName = parseResult.graphId?.let { "${it}Targets" } ?: DEFAULT_TARGETS_FILE_NAME
+internal fun buildTargetsFileSpec(
+ parseResult: SchemaParseResult,
+ config: CodeGenConfig,
+ registry: SchemaRegistry,
+): FileSpec {
val packageName = parseResult.customPackage ?: config.outputPackageName
val rootNode = parseResult.adjacencyList.findRootNode()
return FileSpec.builder(
packageName,
- parseResult.customTargetsFileName ?: targetsFileName,
+ targetsFileName(parseResult),
)
.apply {
parseResult.adjacencyList.forEachFlow { node, _ ->
@@ -23,7 +26,7 @@ internal fun buildTargetsFileSpec(parseResult: SchemaParseResult, config: CodeGe
node,
parseResult.adjacencyList,
isRootNode = node == rootNode,
- buildSegmentId = { buildSegmentId(parseResult.filePath, it) },
+ buildSegmentId = { buildSegmentId(it, parseResult, registry) },
),
)
}
@@ -36,6 +39,7 @@ internal fun buildTargetsFileSpec(parseResult: SchemaParseResult, config: CodeGe
is Node.Flow.Imported,
is Node.Flow.LocalParallel,
is Node.Screen,
+ is Node.History,
-> Unit
}
}
@@ -100,6 +104,18 @@ private fun buildFlowTargets(
}
}
}
+
+ is Node.History -> {
+ // A history child emits its accessor in the nearest ENCLOSING LOCAL FLOW's Targets class.
+ // For a flow-parented history this IS its parent (old behavior); for a parallel-parented
+ // history it routes into the enclosing plain flow. Emitted in exactly one class.
+ val hostFlow = adjacencyList
+ .findAllParents(targetNode, includeThis = false)
+ .firstOrNull { it is Node.Flow.Local }
+ if (hostFlow == node) {
+ addProperty(buildHistoryTargetPropertySpec(node, targetNode, adjacencyList, buildSegmentId))
+ }
+ }
}
}
}
@@ -121,21 +137,18 @@ private fun TypeSpec.Builder.addFlowTarget(
buildSegmentId: (Node) -> String,
): TypeSpec.Builder {
val parameter = targetNode.parameter
+ val kdoc = siblingScreenSchemaKdoc(node, targetNode, adjacencyList)
+ val pathNodes = pathNodesBetween(node, targetNode, adjacencyList)
return if (parameter != null) {
addFunction(
FunSpec.builder(targetNode.id)
- .addParameter(parameter.name, ClassName.bestGuess(parameter.type))
+ .apply { if (kdoc != null) addKdoc(kdoc) }
+ .addParameter(parameter.name, parseTypeName(parameter.type))
.returns(FLOW_TARGET)
.addCode(
"return %T(flowPath(%L), payload = %L)",
FLOW_TARGET,
- buildPathConstructorCall(
- nodes = adjacencyList
- .findAllParents(targetNode, includeThis = true)
- .takeWhile { it != node }
- .reversed(),
- buildSegmentId = buildSegmentId,
- ),
+ buildPathConstructorCall(nodes = pathNodes, buildSegmentId = buildSegmentId),
parameter.name,
)
.build(),
@@ -143,22 +156,62 @@ private fun TypeSpec.Builder.addFlowTarget(
} else {
addProperty(
PropertySpec.builder(targetNode.id, FLOW_TARGET)
+ .apply { if (kdoc != null) addKdoc(kdoc) }
.initializer(
"%T(flowPath(%L))",
FLOW_TARGET,
- buildPathConstructorCall(
- nodes = adjacencyList
- .findAllParents(targetNode, includeThis = true)
- .takeWhile { it != node }
- .reversed(),
- buildSegmentId = buildSegmentId,
- ),
+ buildPathConstructorCall(nodes = pathNodes, buildSegmentId = buildSegmentId),
)
.build(),
)
}
}
+/**
+ * Returns a KDoc string when [targetNode] is a direct child of [node] (no screen intermediary)
+ * AND [node] also has screen siblings at the same level — the pattern that causes accidental screen
+ * dismissal. Returns null when [targetNode] is reached through a screen (intended stacking).
+ */
+private fun siblingScreenSchemaKdoc(node: Node.Flow, targetNode: Node.Flow, adjacencyList: AdjacencyList): String? {
+ if (targetNode !is Node.Flow.Imported) return null // only flag imported schemas, not local flows
+ val directParent = adjacencyList.findParent(targetNode) ?: return null
+ if (directParent != node) return null // goes through a screen — intended stacking
+ val screenSiblings = adjacencyList[node].orEmpty().filterIsInstance()
+ if (screenSiblings.isEmpty()) return null
+ val screens = screenSiblings.joinToString { "`${it.id}`" }
+ return "**Alive-stack note:** navigating here dismisses the current screen in `${node.id}` — " +
+ "alive stack becomes `[${node.id}, ${targetNode.id}]`, not `[${node.id}, screen, ${targetNode.id}]`. " +
+ "Screen sibling(s) in this flow: $screens. " +
+ "To keep a screen alive while entering this schema, move the DOT edge: " +
+ "`${node.id} -> ${targetNode.id}` → `screen -> ${targetNode.id}`."
+}
+
+/**
+ * Emits `public val : HistoryTarget = HistoryTarget(flowPath(Path()), deep = )`.
+ *
+ * [node] is the enclosing LOCAL FLOW whose Targets class this accessor lives in. The emitted path points at
+ * the history node's ACTUAL parent flow or parallel (the node it is declared under) — the flow/parallel whose
+ * most-recently active configuration [ru.kode.way.HistoryTarget] restores — NOT the host [node] and NOT the
+ * history node's own segment. For a flow-parented history the history parent IS [node], so the path is
+ * unchanged; for a parallel-parented history it is the parallel's absolute path.
+ */
+private fun buildHistoryTargetPropertySpec(
+ node: Node.Flow,
+ targetNode: Node.History,
+ adjacencyList: AdjacencyList,
+ buildSegmentId: (Node) -> String,
+): PropertySpec {
+ val historyParent = adjacencyList.findParent(targetNode) ?: node
+ return PropertySpec.builder(targetNode.id, HISTORY_TARGET)
+ .initializer(
+ "%T(flowPath(%L), deep = %L)",
+ HISTORY_TARGET,
+ buildPathConstructorCall(reversedParents(historyParent, adjacencyList), buildSegmentId),
+ targetNode.deep,
+ )
+ .build()
+}
+
private fun buildScreenTargetPropertySpec(
node: Node.Flow,
targetNode: Node.Screen,
@@ -169,10 +222,7 @@ private fun buildScreenTargetPropertySpec(
"%T(flowPath(%L))",
SCREEN_TARGET,
buildPathConstructorCall(
- nodes = adjacencyList
- .findAllParents(targetNode, includeThis = true)
- .takeWhile { it != node }
- .reversed(),
+ nodes = pathNodesBetween(node, targetNode, adjacencyList),
buildSegmentId = buildSegmentId,
),
)
@@ -185,20 +235,25 @@ private fun buildScreenTargetFunSpec(
parameter: Parameter,
buildSegmentId: (Node) -> String,
): FunSpec = FunSpec.builder(targetNode.id)
- .addParameter(parameter.name, ClassName.bestGuess(parameter.type))
+ .addParameter(parameter.name, parseTypeName(parameter.type))
.returns(SCREEN_TARGET)
.addCode(
"return %T(flowPath(%L), payload = %L)",
SCREEN_TARGET,
buildPathConstructorCall(
- nodes = adjacencyList
- .findAllParents(targetNode, includeThis = true)
- .takeWhile { it != node }
- .reversed(),
+ nodes = pathNodesBetween(node, targetNode, adjacencyList),
buildSegmentId = buildSegmentId,
),
parameter.name,
)
.build()
+/**
+ * The chain of nodes from [flow]'s first descendant down to [targetNode] inclusive, in root-to-target
+ * order (i.e. [targetNode] and its ancestors up to but excluding [flow]). This is the node list used
+ * to build the relative path passed to the generated `flowPath(...)`.
+ */
+private fun pathNodesBetween(flow: Node.Flow, targetNode: Node, adjacencyList: AdjacencyList): List =
+ adjacencyList.findAllParents(targetNode, includeThis = true).takeWhile { it != flow }.reversed()
+
internal fun targetsClassName(node: Node.Flow): String = node.id.toPascalCase() + "Targets"
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/AdjacencyListTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/AdjacencyListTest.kt
new file mode 100644
index 0000000..2a3348f
--- /dev/null
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/AdjacencyListTest.kt
@@ -0,0 +1,119 @@
+package ru.kode.way.gradle
+
+import io.kotest.core.spec.style.ShouldSpec
+import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
+import io.kotest.matchers.shouldBe
+
+private fun local(id: String) = Node.Flow.Local(id, "kotlin.Unit", null)
+private fun parallel(id: String) = Node.Flow.LocalParallel(id, "kotlin.Unit", null)
+private fun imported(id: String) = Node.Flow.Imported(id, "kotlin.Unit", null)
+private fun screen(id: String) = Node.Screen(id, null)
+
+class AdjacencyListTest :
+ ShouldSpec({
+ should("region roots of a plain flow graph is the single root flow") {
+ val app = local("app")
+ val screen1 = screen("screen1")
+ val adjacencyList: AdjacencyList = mapOf(
+ app to listOf(screen1),
+ screen1 to emptyList(),
+ )
+
+ buildRegionRoots(adjacencyList).shouldContainExactlyInAnyOrder(app)
+ }
+
+ should("region roots of a parallel-rooted graph are the root parallel's children") {
+ val main = parallel("main")
+ val one = local("one")
+ val two = local("two")
+ val adjacencyList: AdjacencyList = mapOf(
+ main to listOf(one, two),
+ one to listOf(screen("oneScreen")),
+ two to listOf(screen("twoScreen")),
+ )
+
+ buildRegionRoots(adjacencyList).shouldContainExactlyInAnyOrder(one, two)
+ }
+
+ should("region roots of a parallel-in-parallel graph are only the outermost parallel's children") {
+ val main = parallel("main")
+ val one = parallel("one")
+ val two = local("two")
+ val alpha = local("alpha")
+ val beta = local("beta")
+ val adjacencyList: AdjacencyList = mapOf(
+ main to listOf(one, two),
+ one to listOf(alpha, beta),
+ two to listOf(screen("twoScreen")),
+ alpha to listOf(screen("alphaScreen")),
+ beta to listOf(screen("betaScreen")),
+ )
+
+ // `one` and `two` are the top parallel's regions; `alpha`/`beta` belong to `one`'s own
+ // virtual sub-schema and must NOT surface as top-level region roots.
+ buildRegionRoots(adjacencyList).shouldContainExactlyInAnyOrder(one, two)
+ }
+
+ should("a parallel reached through a Local flow surfaces a new flat region tier (acme-tabs shape)") {
+ // Mirrors the REAL, runtime-validated `parallel-test-acme-tabs.dot`:
+ // acmeAppFlow[parallelFlow] -> acmeMainFlow[flow] -> acmeTabsFlow[parallelFlow] -> home/explore
+ // acmeAppFlow[parallelFlow] -> acmeAuthFlow[flow]
+ // `acmeTabsFlow` is a parallel reached through a LOCAL flow (`acmeMainFlow`), so it starts a NEW
+ // region tier: `acmeHomeTab`/`acmeExploreTab` are FLAT region roots alongside the top parallel's own
+ // children `acmeMainFlow`/`acmeAuthFlow`. All four are top-level regions because the runtime region
+ // model is FLAT — `NavigationService.materializeRegion` creates one top-level Region per `schema.regions`
+ // entry keyed by absolute path. This is asserted directly by the runtime contract test
+ // "acme-style layout: each region has its own absolute regionId.path anchored at acmeAppFlow"
+ // in `ParallelNodeTest` (acmeAppFlow → exactly these four regions); a 2-region result would
+ // FAIL it and break navigation. See buildRegionRoots' KDoc for the full flat-model rationale.
+ val acmeAppFlow = parallel("acmeAppFlow")
+ val acmeMainFlow = local("acmeMainFlow")
+ val acmeAuthFlow = local("acmeAuthFlow")
+ val acmeTabsFlow = parallel("acmeTabsFlow")
+ val acmeHomeTab = local("acmeHomeTab")
+ val acmeExploreTab = local("acmeExploreTab")
+ val adjacencyList: AdjacencyList = mapOf(
+ acmeAppFlow to listOf(acmeMainFlow, acmeAuthFlow),
+ acmeMainFlow to listOf(acmeTabsFlow),
+ acmeAuthFlow to listOf(screen("acmeAuthScreen")),
+ acmeTabsFlow to listOf(acmeHomeTab, acmeExploreTab),
+ acmeHomeTab to listOf(screen("acmeHomeScreen")),
+ acmeExploreTab to listOf(screen("acmeExploreScreen")),
+ )
+
+ buildRegionRoots(adjacencyList)
+ .shouldContainExactlyInAnyOrder(acmeMainFlow, acmeAuthFlow, acmeHomeTab, acmeExploreTab)
+ }
+
+ should("virtual sub-schema constructor param order matches call-site arg order (positional forwarding is safe)") {
+ // SchemaCodegen forwards a virtual sub-schema's Imported flows POSITIONALLY:
+ // `VirtualSchema(prop1, prop2, ...)`
+ // where the args come from `dfs(subAdjList, root)` (call site) and the virtual schema's own
+ // constructor params come from `buildConstructorParameters(subAdjList)` — i.e. `subAdjList`
+ // map iteration order. This test pins the invariant that makes that positional forwarding
+ // correct: `subgraphFor` builds `subAdjList` as a LinkedHashMap in DFS order, so its map
+ // order and a fresh `dfs(subAdjList, root)` over it visit Imported flows in the SAME order.
+ val root = parallel("root")
+ val a = imported("a")
+ val b = local("b")
+ val c = imported("c")
+ val d = imported("d")
+ val full: AdjacencyList = mapOf(
+ root to listOf(a, b),
+ a to emptyList(),
+ b to listOf(c, d),
+ c to emptyList(),
+ d to emptyList(),
+ )
+
+ val subAdjList = full.subgraphFor(root)
+
+ // Order the virtual schema's constructor would declare its params in (map iteration order).
+ val constructorParamOrder = subAdjList.keys.filterIsInstance()
+ // Order SchemaCodegen forwards the args in (fresh DFS over the subgraph).
+ val callSiteArgOrder = mutableListOf()
+ dfs(subAdjList, subAdjList.findRootNode()) { n -> if (n is Node.Flow.Imported) callSiteArgOrder.add(n) }
+
+ callSiteArgOrder shouldBe constructorParamOrder
+ }
+ })
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/MalformedDotToleranceTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/MalformedDotToleranceTest.kt
new file mode 100644
index 0000000..1bf843d
--- /dev/null
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/MalformedDotToleranceTest.kt
@@ -0,0 +1,87 @@
+package ru.kode.way.gradle
+
+import io.kotest.core.spec.style.ShouldSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import java.io.File
+
+/**
+ * Regression pins for malformed-but-real DOT syntax found VERBATIM in the consumer apps
+ * (rosseti-android, prsv-android). Each fixture reproduces one offending construct minimally.
+ *
+ * These tests document the CURRENT branch behavior so the reworked [parseSchemaDotFile] cannot
+ * silently change it again. Where the current behavior is a silent drop (not a desired feature),
+ * the test says so explicitly — it pins released behavior, it does not endorse it.
+ */
+class MalformedDotToleranceTest :
+ ShouldSpec({
+
+ fun parse(name: String) = parseSchemaDotFile(File("src/test/resources/$name"), projectDir = File("."))
+
+ // CASE 1 — prsv (main_flow.dot, shelf_book_categories_flow.dot):
+ // parameterName "categoryType" <- NO '=' sign, bare quoted value.
+ //
+ // This appears VERBATIM in real prsv graphs. master's positional `chunked(2)` attribute parser
+ // paired `parameterName` with the bare `"categoryType"` and accepted it. The branch's reworked
+ // pairing loop initially required a literal `=`, DROPPED the bare attribute, orphaned the sibling
+ // `parameterType`, and the both-or-neither validation HARD-THREW — a build-breaking regression on
+ // real prsv files. Fixed in Parser.kt by pairing two adjacent ids positionally when there is no
+ // `=`, restoring master's tolerance. This test locks that: the malformed attribute now parses.
+ should("parameterName without '=' is tolerated and parsed positionally (prsv regression)") {
+ val result = parse("malformed-param-name-no-equals.dot")
+ val categoriesFlow = result.adjacencyList.keys.single { it.id == "categoriesFlow" }
+ categoriesFlow.shouldNotBeNull()
+ categoriesFlow as Node.Flow.Imported
+ categoriesFlow.parameter shouldBe Parameter(name = "categoryType", type = "kotlin.String")
+ }
+
+ // CASE 2 — prsv (main_flow.dot bookFlow) + rosseti: a parameterType line with NO trailing
+ // comma immediately followed by a resultType line. Both attributes must still be parsed.
+ should("adjacent attributes without a comma separator are both parsed (prsv/rosseti regression)") {
+ val result = parse("malformed-adjacent-attrs-no-comma.dot")
+ val bookFlow = result.adjacencyList.keys.single { it.id == "bookFlow" }
+ bookFlow.shouldNotBeNull()
+ bookFlow as Node.Flow.Imported
+ // parameterType (no trailing comma) AND the following resultType are both captured.
+ bookFlow.parameter shouldBe Parameter(name = "config", type = "kotlin.String")
+ bookFlow.resultType shouldBe "kotlin.Int"
+ }
+
+ // CASE 3 — rosseti (mapaddress_flow.dot): `result = "..."` used instead of `resultType`.
+ // The unknown `result` key is silently ignored; resultType stays at its default kotlin.Unit.
+ // This pins released behavior (silent ignore), it does not endorse the typo.
+ should("unknown 'result' attribute is silently ignored, resultType stays default (rosseti regression)") {
+ val result = parse("malformed-result-typo.dot")
+ val mapAddressFlow = result.adjacencyList.keys.single { it.id == "mapAddressFlow" } as Node.Flow.Local
+ mapAddressFlow.resultType shouldBe "kotlin.Unit"
+ mapAddressFlow.parameter.shouldBeNull()
+ }
+
+ // CASE 4 — rosseti: a parent imports `instrumentDocumentDetailsFlow [type=schema]` but the
+ // imported child .dot's ROOT node is named `documentDetailsFlow` (a different alias). The
+ // registry is keyed by the child's real root-node id, so resolving by the parent's alias
+ // returns null and the caller falls back to the owner's @file identity. Pin both facts.
+ should(
+ "import alias that differs from the child root name resolves to null (owner @file fallback) (rosseti regression)",
+ ) {
+ val child = parse("malformed-import-alias-mismatch-child.dot")
+ val registry = SchemaRegistry.from(listOf(child))
+
+ // Resolving by the parent's alias finds nothing — the registry only knows the real root name.
+ registry.resolveSource("instrumentDocumentDetailsFlow").shouldBeNull()
+ // The child's actual root name still resolves, confirming it IS registered.
+ registry.resolveSource("documentDetailsFlow").shouldNotBeNull()
+ }
+
+ // CASE 5 — rosseti (main_flow.dot): the same edge `main -> shutdownScheduleFlow` declared on
+ // two lines. The adjacency set de-duplicates, so the child appears exactly once and parsing
+ // does not crash.
+ should("a duplicate edge is de-duplicated and does not crash (rosseti regression)") {
+ val result = parse("malformed-duplicate-edge.dot")
+ val appFlow = result.adjacencyList.keys.single { it.id == "appFlow" }
+ val children = result.adjacencyList[appFlow].orEmpty()
+ children.map { it.id } shouldBe listOf("shutdownFlow")
+ }
+ })
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/NodeBuilderGenerateTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/NodeBuilderGenerateTest.kt
index 508454e..35f20ee 100644
--- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/NodeBuilderGenerateTest.kt
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/NodeBuilderGenerateTest.kt
@@ -12,6 +12,7 @@ class NodeBuilderGenerateTest :
"Nb01appNodeBuilder.txt",
"Nb01loginNodeBuilder.txt",
"Nb01onboardingNodeBuilder.txt",
+ "Nb01appChildFinishRequest.txt",
),
testName = "multiple flows",
),
@@ -28,6 +29,7 @@ class NodeBuilderGenerateTest :
"Nbp01mainNodeBuilder.txt",
"Nbp01headNodeBuilder.txt",
"Nbp01sheetNodeBuilder.txt",
+ "Nbp01mainChildFinishRequest.txt",
),
testName = "parallel node children in same schema",
),
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/ParseTypeNameTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/ParseTypeNameTest.kt
new file mode 100644
index 0000000..bb8a852
--- /dev/null
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/ParseTypeNameTest.kt
@@ -0,0 +1,31 @@
+package ru.kode.way.gradle
+
+import com.squareup.kotlinpoet.ClassName
+import io.kotest.core.spec.style.ShouldSpec
+import io.kotest.matchers.shouldBe
+
+class ParseTypeNameTest :
+ ShouldSpec({
+ should("mark a type with a trailing '?' nullable") {
+ val type = parseTypeName("kotlin.String?")
+ type.isNullable shouldBe true
+ type.copy(nullable = false) shouldBe ClassName.bestGuess("kotlin.String")
+ }
+
+ should("keep a type without '?' non-null") {
+ val type = parseTypeName("kotlin.String")
+ type.isNullable shouldBe false
+ type shouldBe ClassName.bestGuess("kotlin.String")
+ }
+
+ should("support fully-qualified nullable types") {
+ val type = parseTypeName("java.nio.Charset?")
+ type.isNullable shouldBe true
+ type.copy(nullable = false) shouldBe ClassName.bestGuess("java.nio.Charset")
+ }
+
+ should("tolerate surrounding whitespace") {
+ parseTypeName(" kotlin.String? ").isNullable shouldBe true
+ parseTypeName(" kotlin.String ").isNullable shouldBe false
+ }
+ })
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 a9840d1..cf8b136 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
@@ -33,12 +33,20 @@ class SchemaGenerateTest :
),
TestCase(
schemaFile = "schema-parallel01.dot",
- expectedOutputFiles = listOf("schema-parallel01.txt"),
+ expectedOutputFiles = listOf("schema-parallel01.txt", "TestAppRegion.txt"),
testName = "basic parallel flow schema",
),
TestCase(
schemaFile = "schema-parallel02.dot",
- expectedOutputFiles = listOf("schema-parallel02.txt"),
+ expectedOutputFiles = listOf(
+ "schema-parallel02.txt",
+ "MainChildFinishRequest.txt",
+ "OneChildFinishRequest.txt",
+ "OneSchema.txt",
+ // Region enum for the top-level parallel; the nested OneSchema also emits one
+ // (`OneRegion.kt`) but its fixture coverage is left to schema-parallel01's enum
+ // assertion since the generation logic is identical.
+ ),
testName = "multiple parallel in one schema",
),
) { runTest(it) }
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaRegistryTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaRegistryTest.kt
new file mode 100644
index 0000000..cb9c04f
--- /dev/null
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaRegistryTest.kt
@@ -0,0 +1,155 @@
+package ru.kode.way.gradle
+
+import io.kotest.core.spec.style.ShouldSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.nulls.shouldNotBeNull
+import io.kotest.matchers.shouldBe
+import java.nio.file.Paths
+
+class SchemaRegistryTest :
+ ShouldSpec({
+
+ should("resolveSource returns the single match when one schema has the requested root name") {
+ val login = parseResult(
+ filePath = "feature/login/way/login.dot",
+ customPackage = "com.example.login",
+ rootName = "loginFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(login))
+
+ val resolved = registry.resolveSource("loginFlow")
+
+ resolved.shouldNotBeNull()
+ resolved.filePath.toString() shouldBe Paths.get("feature/login/way/login.dot").toString()
+ }
+
+ should("resolveSource returns null when the root name is unknown") {
+ val login = parseResult(
+ filePath = "feature/login/way/login.dot",
+ customPackage = "com.example.login",
+ rootName = "loginFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(login))
+
+ registry.resolveSource("doesNotExist").shouldBeNull()
+ }
+
+ // Multiple .dot files in the same project may declare a same-named root (test fixtures
+ // routinely do this; production may too across feature modules). Without a tiebreaker the
+ // lookup is ambiguous and must return null.
+ should("resolveSource returns null when multiple schemas share a root name and no importingPackage is supplied") {
+ val moduleA = parseResult(
+ filePath = "module-a/way/app.dot",
+ customPackage = "com.example.a",
+ rootName = "appFlow",
+ )
+ val moduleB = parseResult(
+ filePath = "module-b/way/app.dot",
+ customPackage = "com.example.b",
+ rootName = "appFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(moduleA, moduleB))
+
+ registry.resolveSource("appFlow").shouldBeNull()
+ }
+
+ // Same-package preference: if exactly one of the ambiguous candidates is in the importer's
+ // package, it wins. This is how cross-module imports stay unambiguous: each consumer module
+ // imports its own copy.
+ should("resolveSource picks the same-package candidate when importingPackage uniquely identifies one match") {
+ val moduleA = parseResult(
+ filePath = "module-a/way/app.dot",
+ customPackage = "com.example.a",
+ rootName = "appFlow",
+ )
+ val moduleB = parseResult(
+ filePath = "module-b/way/app.dot",
+ customPackage = "com.example.b",
+ rootName = "appFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(moduleA, moduleB))
+
+ val resolved = registry.resolveSource("appFlow", importingPackage = "com.example.b")
+
+ resolved.shouldNotBeNull()
+ resolved.customPackage shouldBe "com.example.b"
+ }
+
+ // The importingPackage hint is a disambiguator, not a filter. If no candidate matches the
+ // package, the lookup stays ambiguous and returns null — same as if the hint were absent.
+ should("resolveSource returns null when importingPackage matches none of the candidates") {
+ val moduleA = parseResult(
+ filePath = "module-a/way/app.dot",
+ customPackage = "com.example.a",
+ rootName = "appFlow",
+ )
+ val moduleB = parseResult(
+ filePath = "module-b/way/app.dot",
+ customPackage = "com.example.b",
+ rootName = "appFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(moduleA, moduleB))
+
+ registry.resolveSource("appFlow", importingPackage = "com.example.unrelated").shouldBeNull()
+ }
+
+ // Same-package preference does NOT promote a candidate when more than one candidate is in
+ // the importer's package. Two co-located fixtures with the same root would still be
+ // ambiguous and the caller must fall back to the owner's identity.
+ should("resolveSource returns null when more than one same-package candidate exists") {
+ val moduleA = parseResult(
+ filePath = "module-a/way/app.dot",
+ customPackage = "com.example.a",
+ rootName = "appFlow",
+ )
+ val moduleAExtra = parseResult(
+ filePath = "module-a/way/app_alt.dot",
+ customPackage = "com.example.a",
+ rootName = "appFlow",
+ )
+ val moduleB = parseResult(
+ filePath = "module-b/way/app.dot",
+ customPackage = "com.example.b",
+ rootName = "appFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(moduleA, moduleAExtra, moduleB))
+
+ registry.resolveSource("appFlow", importingPackage = "com.example.a").shouldBeNull()
+ }
+
+ // Empty adjacency lists are skipped: SchemaRegistry.from must not crash on a parseResult
+ // with no nodes (this can happen for stub fixtures or malformed inputs). They contribute
+ // nothing to the registry index.
+ should("from skips parseResults with an empty adjacency list") {
+ val empty = parseResult(
+ filePath = "empty/way/empty.dot",
+ customPackage = "com.example.empty",
+ rootName = null,
+ )
+ val real = parseResult(
+ filePath = "feature/login/way/login.dot",
+ customPackage = "com.example.login",
+ rootName = "loginFlow",
+ )
+ val registry = SchemaRegistry.from(listOf(empty, real))
+
+ registry.resolveSource("loginFlow").shouldNotBeNull()
+ }
+ })
+
+private fun parseResult(filePath: String, customPackage: String, rootName: String?): SchemaParseResult {
+ val adjacency: AdjacencyList = if (rootName == null) {
+ emptyMap()
+ } else {
+ mapOf(Node.Flow.Local(id = rootName, resultType = "Unit", parameter = null) to emptyList())
+ }
+ return SchemaParseResult(
+ filePath = Paths.get(filePath),
+ graphId = null,
+ customSchemaFileName = null,
+ customSchemaClassName = null,
+ customTargetsFileName = null,
+ customPackage = customPackage,
+ adjacencyList = adjacency,
+ )
+}
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaValidationTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaValidationTest.kt
new file mode 100644
index 0000000..d77227b
--- /dev/null
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaValidationTest.kt
@@ -0,0 +1,168 @@
+package ru.kode.way.gradle
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.ShouldSpec
+import io.kotest.matchers.collections.shouldContain
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import java.io.File
+import java.nio.file.Paths
+
+class SchemaValidationTest :
+ ShouldSpec({
+
+ should("detect cycles in schema") {
+ // validation-cycle.dot: screen1 -> screen2 -> screen1 creates a cycle
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-cycle.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "cycle"
+ }
+
+ should("reject a flow with no children") {
+ // validation-empty-flow.dot: 'app' is a root flow with no outgoing edges
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-empty-flow.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "no children"
+ }
+
+ should("detect disconnected subgraph") {
+ // validation-disconnected.dot: 'orphan' has no edges and is not reachable from 'app'
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-disconnected.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "disconnected"
+ }
+
+ should("detect fan-in (node with multiple parents)") {
+ // validation-fan-in.dot: 'shared' has two incoming edges from screen1 and screen2
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-fan-in.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "fan-in"
+ }
+
+ should("reject a non-root parallel node with no children") {
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-empty-parallel.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "no children"
+ }
+
+ should("reject a node with parameterName but no parameterType") {
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-half-param.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "parameterName and parameterType"
+ }
+
+ should("reject a schema with multiple root flows") {
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-multi-root.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "multiple root flows"
+ }
+
+ should("reject a node id containing a hyphen") {
+ // validation-invalid-node-id.dot: "login-screen" uses a hyphen which is not a valid Kotlin identifier
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-invalid-node-id.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "invalid node id"
+ }
+
+ should("reject a graph id containing a hyphen") {
+ // validation-invalid-graph-id.dot: digraph "bad-graph" uses a hyphen which is not a valid Kotlin identifier
+ val ex = shouldThrow {
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-invalid-graph-id.dot"),
+ projectDir = File("."),
+ )
+ }
+ ex.message!! shouldContain "invalid graph id"
+ }
+
+ should("detect output file name collisions between two schema files") {
+ val config = CodeGenConfig(outputPackageName = "com.example", outputSchemaClassName = "AppSchema")
+ // Two results with graphId=null and no custom file names both resolve to config.outputSchemaClassName
+ val result1 = SchemaParseResult(
+ filePath = Paths.get("schema1.dot"),
+ graphId = null,
+ customSchemaFileName = null,
+ customTargetsFileName = null,
+ customPackage = null,
+ adjacencyList = emptyMap(),
+ )
+ val result2 = SchemaParseResult(
+ filePath = Paths.get("schema2.dot"),
+ graphId = null,
+ customSchemaFileName = null,
+ customTargetsFileName = null,
+ customPackage = null,
+ adjacencyList = emptyMap(),
+ )
+ val ex = shouldThrow {
+ validateNoOutputFileCollisions(listOf(result1, result2), config)
+ }
+ ex.message!! shouldContain "AppSchema"
+ }
+
+ should("warn when screen and schema are siblings under the same flow") {
+ // validation-sibling-screen-schema.dot: mainFlow -> main (screen) AND mainFlow -> chatFlow (schema).
+ // Navigating to chatFlow dismisses main — the warning explains the alive-stack consequence and
+ // suggests moving the edge to "main -> chatFlow" if stacking was the intent.
+ val warnings = mutableListOf()
+ parseSchemaDotFile(
+ file = File("src/test/resources/validation-sibling-screen-schema.dot"),
+ projectDir = File("."),
+ warn = warnings::add,
+ )
+ warnings.size shouldBe 1
+ warnings[0] shouldContain "mainFlow"
+ warnings[0] shouldContain "chatFlow"
+ warnings[0] shouldContain "main"
+ warnings[0] shouldContain "alive stack"
+ }
+
+ should("parse quoted node names correctly (H2 regression test)") {
+ // validation-quoted-nodes.dot: node ids use double-quoted strings; the H2 fix ensures
+ // id2.id_().asString() is called instead of id2.text so the surrounding quotes are stripped.
+ val result = parseSchemaDotFile(
+ file = File("src/test/resources/validation-quoted-nodes.dot"),
+ projectDir = File("."),
+ )
+ val nodeIds = result.adjacencyList.keys.map { it.id }
+ nodeIds shouldContain "myFlow"
+ nodeIds shouldContain "myScreen"
+ // 'myFlow' is a local flow with one child 'myScreen'
+ val flow = result.adjacencyList.keys.single { it.id == "myFlow" }
+ flow shouldBe Node.Flow.Local("myFlow", "kotlin.Unit", null)
+ val children = result.adjacencyList[flow].orEmpty()
+ children.map { it.id } shouldBe listOf("myScreen")
+ }
+ })
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TargetGenerateTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TargetGenerateTest.kt
index 8f9cac9..5bed483 100644
--- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TargetGenerateTest.kt
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TargetGenerateTest.kt
@@ -26,5 +26,20 @@ class TargetGenerateTest :
expectedOutputFiles = listOf("targets-test04-targets.txt"),
testName = "targets with arguments",
),
+ TestCase(
+ schemaFile = "targets-test05.dot",
+ expectedOutputFiles = listOf("targets-test05-targets.txt", "AppChildFinishRequest.txt"),
+ testName = "nullable parameter and result types",
+ ),
+ TestCase(
+ schemaFile = "targets-test06.dot",
+ expectedOutputFiles = listOf("targets-test06-targets.txt"),
+ testName = "history pseudostate targets",
+ ),
+ TestCase(
+ schemaFile = "targets-test07.dot",
+ expectedOutputFiles = listOf("targets-test07-targets.txt"),
+ testName = "history under a parallel parent",
+ ),
) { runTest(it) }
})
diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TestRunner.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TestRunner.kt
index 8b7b6b0..c38cc96 100644
--- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TestRunner.kt
+++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/TestRunner.kt
@@ -24,6 +24,9 @@ suspend fun runTest(testCase: TestCase) {
schemaFileSpec.writeTo(outputDirectory.toNioPath())
targetsFileSpec.writeTo(outputDirectory.toNioPath())
nodeBuilderSpecs.forEach { it.writeTo(outputDirectory.toNioPath()) }
+ finishEventsFileSpecs.forEach { it.writeTo(outputDirectory.toNioPath()) }
+ virtualSchemaFileSpecs.forEach { it.writeTo(outputDirectory.toNioPath()) }
+ regionEnumFileSpec?.writeTo(outputDirectory.toNioPath())
}
expectedResults.forEach { expectedFile ->
FileSystem.SYSTEM.apply {
diff --git a/way-gradle-plugin/src/test/resources/AppChildFinishRequest.txt b/way-gradle-plugin/src/test/resources/AppChildFinishRequest.txt
new file mode 100644
index 0000000..89e8a38
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/AppChildFinishRequest.txt
@@ -0,0 +1,10 @@
+package ru.kode.test.app.schema
+
+import kotlin.String
+import ru.kode.way.Event
+
+public sealed interface AppChildFinishRequest : Event {
+ public data class Child(
+ public val result: String?,
+ ) : AppChildFinishRequest
+}
diff --git a/way-gradle-plugin/src/test/resources/MainChildFinishRequest.txt b/way-gradle-plugin/src/test/resources/MainChildFinishRequest.txt
new file mode 100644
index 0000000..4cea470
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/MainChildFinishRequest.txt
@@ -0,0 +1,9 @@
+package ru.kode.test.app.schema
+
+import ru.kode.way.Event
+
+public sealed interface MainChildFinishRequest : Event {
+ public data object One : MainChildFinishRequest
+
+ public data object Two : MainChildFinishRequest
+}
diff --git a/way-gradle-plugin/src/test/resources/Nb01appChildFinishRequest.txt b/way-gradle-plugin/src/test/resources/Nb01appChildFinishRequest.txt
new file mode 100644
index 0000000..fc010cb
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/Nb01appChildFinishRequest.txt
@@ -0,0 +1,15 @@
+package ru.kode.test.app.schema
+
+import ru.kode.test.app.LoginFlowResult
+import ru.kode.test.app.OnboardingFlowResult
+import ru.kode.way.Event
+
+public sealed interface Nb01appChildFinishRequest : Event {
+ public data class Nb01login(
+ public val result: LoginFlowResult,
+ ) : Nb01appChildFinishRequest
+
+ public data class Nb01onboarding(
+ public val result: OnboardingFlowResult,
+ ) : Nb01appChildFinishRequest
+}
diff --git a/way-gradle-plugin/src/test/resources/Nb01appNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nb01appNodeBuilder.txt
index 2884013..e8dd3bd 100644
--- a/way-gradle-plugin/src/test/resources/Nb01appNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nb01appNodeBuilder.txt
@@ -5,6 +5,7 @@ import kotlin.Suppress
import kotlin.collections.HashMap
import kotlin.collections.Map
import kotlin.collections.MutableMap
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -21,11 +22,11 @@ public class Nb01appNodeBuilder(
) : NodeBuilder {
private val nodeBuilders: MutableMap = HashMap(2)
- private fun nb01loginNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nb01login@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) {
+ private fun nb01loginNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nb01login@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) {
nodeFactory.createNb01loginNodeBuilder()
}
- private fun nb01onboardingNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nb01onboarding@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) {
+ private fun nb01onboardingNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nb01onboarding@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) {
nodeFactory.createNb01onboardingNodeBuilder()
}
@@ -34,37 +35,36 @@ public class Nb01appNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01app@src/test/resources/node-builders-multiple-flows.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01app@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nb01app" node: $path"""
}
return when {
path == rootPath -> nodeFactory.createRootNode()
- path.startsWith(targetOrError(Segment("nb01login@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nb01login@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nb01login@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nb01login@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)
val nodeBuilder = nb01loginNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
- path.startsWith(targetOrError(Segment("nb01onboarding@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nb01onboarding@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nb01onboarding@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nb01onboarding@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias)
val nodeBuilder = nb01onboardingNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
else -> error("""illegal path build requested for "nb01app" node: $path""")
}
}
- override fun invalidateCache(path: Path) {
- nodeBuilders.keys.filter { !path.startsWith(it) }.forEach {
- println("""${this::class.simpleName}: removing nodeBuilder for $it""")
- }
- nodeBuilders.keys.retainAll { path.startsWith(it) }
+ override fun invalidateCache(alivePaths: Set) {
+ nodeBuilders.keys.retainAll { key -> alivePaths.any { it.startsWith(key) } }
nodeBuilders.forEach { (builderPath, builder) ->
- builder.invalidateCache(path.drop(builderPath.length - 1))
+ val drop = builderPath.length - 1
+ val childAlive = alivePaths.filter { it.startsWith(builderPath) && it.length > drop }.map { it.drop(drop) }.toSet()
+ builder.invalidateCache(childAlive)
}
}
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nb01loginNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nb01loginNodeBuilder.txt
index c3e3ea5..6c860a1 100644
--- a/way-gradle-plugin/src/test/resources/Nb01loginNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nb01loginNodeBuilder.txt
@@ -4,6 +4,7 @@ import kotlin.Any
import kotlin.Suppress
import kotlin.Unit
import kotlin.collections.Map
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -21,19 +22,20 @@ public class Nb01loginNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01login@src/test/resources/node-builders-multiple-flows.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01login@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nb01login" node: $path"""
}
return when {
- path == targetOrError(Segment("nb01credentials@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias) -> nodeFactory.createNb01credentialsNode()
+ path == rootPath -> nodeFactory.createRootNode()
+ path == targetOrError(Segment("nb01credentials@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias) -> nodeFactory.createNb01credentialsNode()
else -> error("""illegal path build requested for "nb01login" node: $path""")
}
}
- override fun invalidateCache(path: Path): Unit = Unit
+ override fun invalidateCache(alivePaths: Set): Unit = Unit
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nb01onboardingNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nb01onboardingNodeBuilder.txt
index 96f7a07..57354dd 100644
--- a/way-gradle-plugin/src/test/resources/Nb01onboardingNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nb01onboardingNodeBuilder.txt
@@ -4,6 +4,7 @@ import kotlin.Any
import kotlin.Suppress
import kotlin.Unit
import kotlin.collections.Map
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -21,19 +22,20 @@ public class Nb01onboardingNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01onboarding@src/test/resources/node-builders-multiple-flows.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb01onboarding@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nb01onboarding" node: $path"""
}
return when {
- path == targetOrError(Segment("nb01intro@src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias) -> nodeFactory.createNb01introNode()
+ path == rootPath -> nodeFactory.createRootNode()
+ path == targetOrError(Segment("nb01intro@NodeBuildersTest01:src/test/resources/node-builders-multiple-flows.dot"), rootSegmentAlias) -> nodeFactory.createNb01introNode()
else -> error("""illegal path build requested for "nb01onboarding" node: $path""")
}
}
- override fun invalidateCache(path: Path): Unit = Unit
+ override fun invalidateCache(alivePaths: Set): Unit = Unit
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nb02appNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nb02appNodeBuilder.txt
index 94adb4c..e543892 100644
--- a/way-gradle-plugin/src/test/resources/Nb02appNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nb02appNodeBuilder.txt
@@ -4,6 +4,7 @@ import kotlin.Any
import kotlin.Suppress
import kotlin.Unit
import kotlin.collections.Map
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -21,22 +22,22 @@ public class Nb02appNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb02app@src/test/resources/node-builders-single-flow.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nb02app@NodeBuildersTest02:src/test/resources/node-builders-single-flow.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nb02app" node: $path"""
}
return when {
path == rootPath -> nodeFactory.createRootNode()
- path == targetOrError(Segment("nb02screen3@src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen3Node()
- path == targetOrError(Segment("nb02screen1@src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen1Node()
- path == targetOrError(Segment("nb02screen2@src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen2Node()
+ path == targetOrError(Segment("nb02screen3@NodeBuildersTest02:src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen3Node()
+ path == targetOrError(Segment("nb02screen1@NodeBuildersTest02:src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen1Node()
+ path == targetOrError(Segment("nb02screen2@NodeBuildersTest02:src/test/resources/node-builders-single-flow.dot"), rootSegmentAlias) -> nodeFactory.createNb02screen2Node()
else -> error("""illegal path build requested for "nb02app" node: $path""")
}
}
- override fun invalidateCache(path: Path): Unit = Unit
+ override fun invalidateCache(alivePaths: Set): Unit = Unit
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nbp01headNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nbp01headNodeBuilder.txt
index 79d63f9..e07520a 100644
--- a/way-gradle-plugin/src/test/resources/Nbp01headNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nbp01headNodeBuilder.txt
@@ -4,6 +4,7 @@ import kotlin.Any
import kotlin.Suppress
import kotlin.Unit
import kotlin.collections.Map
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -13,25 +14,26 @@ import ru.kode.way.firstSegment
public class Nbp01headNodeBuilder(
private val nodeFactory: Factory,
- override val schema: NodeBuildersParallel01Schema,
+ override val schema: Nbp01headSchema,
) : NodeBuilder {
override fun build(
path: Path,
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01head@src/test/resources/node-builders-parallel01.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01head@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nbp01head" node: $path"""
}
return when {
+ path == rootPath -> nodeFactory.createRootNode()
else -> error("""illegal path build requested for "nbp01head" node: $path""")
}
}
- override fun invalidateCache(path: Path): Unit = Unit
+ override fun invalidateCache(alivePaths: Set): Unit = Unit
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nbp01mainChildFinishRequest.txt b/way-gradle-plugin/src/test/resources/Nbp01mainChildFinishRequest.txt
new file mode 100644
index 0000000..bd8c874
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/Nbp01mainChildFinishRequest.txt
@@ -0,0 +1,9 @@
+package ru.kode.test.app.schema
+
+import ru.kode.way.Event
+
+public sealed interface Nbp01mainChildFinishRequest : Event {
+ public data object Nbp01head : Nbp01mainChildFinishRequest
+
+ public data object Nbp01sheet : Nbp01mainChildFinishRequest
+}
diff --git a/way-gradle-plugin/src/test/resources/Nbp01mainNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nbp01mainNodeBuilder.txt
index 07ba01a..04b884a 100644
--- a/way-gradle-plugin/src/test/resources/Nbp01mainNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nbp01mainNodeBuilder.txt
@@ -5,10 +5,12 @@ import kotlin.Suppress
import kotlin.collections.HashMap
import kotlin.collections.Map
import kotlin.collections.MutableMap
+import kotlin.collections.Set
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
-import ru.kode.way.ParallelNode
+import ru.kode.way.ParallelFlowNode
import ru.kode.way.Path
+import ru.kode.way.RegionId
import ru.kode.way.Segment
import ru.kode.way.drop
import ru.kode.way.firstSegment
@@ -21,11 +23,17 @@ public class Nbp01mainNodeBuilder(
) : NodeBuilder {
private val nodeBuilders: MutableMap = HashMap(2)
- private fun nbp01sheetNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp01sheet@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) {
+ public val nbp01headRegionId: RegionId
+ get() = RegionId(Path(listOf(Segment("nbp01main@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), Segment("nbp01head@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"))))
+
+ public val nbp01sheetRegionId: RegionId
+ get() = RegionId(Path(listOf(Segment("nbp01main@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), Segment("nbp01sheet@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"))))
+
+ private fun nbp01sheetNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp01sheet@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) {
nodeFactory.createNbp01sheetNodeBuilder()
}
- private fun nbp01headNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp01head@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) {
+ private fun nbp01headNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp01head@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) {
nodeFactory.createNbp01headNodeBuilder()
}
@@ -34,37 +42,36 @@ public class Nbp01mainNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01main@src/test/resources/node-builders-parallel01.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01main@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nbp01main" node: $path"""
}
return when {
path == rootPath -> nodeFactory.createRootNode()
- path.startsWith(targetOrError(Segment("nbp01sheet@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nbp01sheet@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nbp01sheet@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nbp01sheet@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)
val nodeBuilder = nbp01sheetNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
- path.startsWith(targetOrError(Segment("nbp01head@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nbp01head@src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nbp01head@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nbp01head@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"), rootSegmentAlias)
val nodeBuilder = nbp01headNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
else -> error("""illegal path build requested for "nbp01main" node: $path""")
}
}
- override fun invalidateCache(path: Path) {
- nodeBuilders.keys.filter { !path.startsWith(it) }.forEach {
- println("""${this::class.simpleName}: removing nodeBuilder for $it""")
- }
- nodeBuilders.keys.retainAll { path.startsWith(it) }
+ override fun invalidateCache(alivePaths: Set) {
+ nodeBuilders.keys.retainAll { key -> alivePaths.any { it.startsWith(key) } }
nodeBuilders.forEach { (builderPath, builder) ->
- builder.invalidateCache(path.drop(builderPath.length - 1))
+ val drop = builderPath.length - 1
+ val childAlive = alivePaths.filter { it.startsWith(builderPath) && it.length > drop }.map { it.drop(drop) }.toSet()
+ builder.invalidateCache(childAlive)
}
}
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
@@ -78,7 +85,7 @@ public class Nbp01mainNodeBuilder(
}
public interface Factory {
- public fun createRootNode(): ParallelNode
+ public fun createRootNode(): ParallelFlowNode<*>
public fun createNbp01sheetNodeBuilder(): NodeBuilder
diff --git a/way-gradle-plugin/src/test/resources/Nbp01sheetNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nbp01sheetNodeBuilder.txt
index 72b95e7..a1b561f 100644
--- a/way-gradle-plugin/src/test/resources/Nbp01sheetNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nbp01sheetNodeBuilder.txt
@@ -4,6 +4,7 @@ import kotlin.Any
import kotlin.Suppress
import kotlin.Unit
import kotlin.collections.Map
+import kotlin.collections.Set
import ru.kode.way.FlowNode
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
@@ -13,25 +14,26 @@ import ru.kode.way.firstSegment
public class Nbp01sheetNodeBuilder(
private val nodeFactory: Factory,
- override val schema: NodeBuildersParallel01Schema,
+ override val schema: Nbp01sheetSchema,
) : NodeBuilder {
override fun build(
path: Path,
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01sheet@src/test/resources/node-builders-parallel01.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp01sheet@NodeBuildersParallel01:src/test/resources/node-builders-parallel01.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nbp01sheet" node: $path"""
}
return when {
+ path == rootPath -> nodeFactory.createRootNode()
else -> error("""illegal path build requested for "nbp01sheet" node: $path""")
}
}
- override fun invalidateCache(path: Path): Unit = Unit
+ override fun invalidateCache(alivePaths: Set): Unit = Unit
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
diff --git a/way-gradle-plugin/src/test/resources/Nbp02mainNodeBuilder.txt b/way-gradle-plugin/src/test/resources/Nbp02mainNodeBuilder.txt
index 3dbe830..bb0dcc8 100644
--- a/way-gradle-plugin/src/test/resources/Nbp02mainNodeBuilder.txt
+++ b/way-gradle-plugin/src/test/resources/Nbp02mainNodeBuilder.txt
@@ -5,10 +5,12 @@ import kotlin.Suppress
import kotlin.collections.HashMap
import kotlin.collections.Map
import kotlin.collections.MutableMap
+import kotlin.collections.Set
import ru.kode.way.Node
import ru.kode.way.NodeBuilder
-import ru.kode.way.ParallelNode
+import ru.kode.way.ParallelFlowNode
import ru.kode.way.Path
+import ru.kode.way.RegionId
import ru.kode.way.Segment
import ru.kode.way.drop
import ru.kode.way.firstSegment
@@ -21,11 +23,17 @@ public class Nbp02mainNodeBuilder(
) : NodeBuilder {
private val nodeBuilders: MutableMap = HashMap(2)
- private fun nbp02sheetNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp02sheet@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) {
+ public val nbp02headRegionId: RegionId
+ get() = RegionId(Path(listOf(Segment("nbp02main@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), Segment("nbp02head@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"))))
+
+ public val nbp02sheetRegionId: RegionId
+ get() = RegionId(Path(listOf(Segment("nbp02main@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), Segment("nbp02sheet@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"))))
+
+ private fun nbp02sheetNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp02sheet@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) {
nodeFactory.createNbp02sheetNodeBuilder()
}
- private fun nbp02headNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp02head@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) {
+ private fun nbp02headNodeBuilder(rootSegmentAlias: Segment?): NodeBuilder = nodeBuilders.getOrPut(targetOrError(Segment("nbp02head@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) {
nodeFactory.createNbp02headNodeBuilder()
}
@@ -34,37 +42,36 @@ public class Nbp02mainNodeBuilder(
payloads: Map,
rootSegmentAlias: Segment?,
): Node {
- val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp02main@src/test/resources/node-builders-parallel02.dot"))
+ val rootPath = rootSegmentAlias?.let { Path(it) } ?: Path(Segment("nbp02main@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"))
check(path.firstSegment().id == rootPath.firstSegment().id) {
"""illegal path build requested for "nbp02main" node: $path"""
}
return when {
path == rootPath -> nodeFactory.createRootNode()
- path.startsWith(targetOrError(Segment("nbp02sheet@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nbp02sheet@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nbp02sheet@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nbp02sheet@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)
val nodeBuilder = nbp02sheetNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
- path.startsWith(targetOrError(Segment("nbp02head@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) -> {
- val targetPath = targetOrError(Segment("nbp02head@src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)
+ path.startsWith(targetOrError(Segment("nbp02head@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)) -> {
+ val targetPath = targetOrError(Segment("nbp02head@NodeBuildersParallel02:src/test/resources/node-builders-parallel02.dot"), rootSegmentAlias)
val nodeBuilder = nbp02headNodeBuilder(rootSegmentAlias)
- nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
+ nodeBuilder.build(path.drop(targetPath.length - 1), payloads = payloads.filterKeys { it.length > targetPath.length - 1 }.mapKeys { it.key.drop(targetPath.length - 1) }, rootSegmentAlias = targetPath.lastSegment())
}
else -> error("""illegal path build requested for "nbp02main" node: $path""")
}
}
- override fun invalidateCache(path: Path) {
- nodeBuilders.keys.filter { !path.startsWith(it) }.forEach {
- println("""${this::class.simpleName}: removing nodeBuilder for $it""")
- }
- nodeBuilders.keys.retainAll { path.startsWith(it) }
+ override fun invalidateCache(alivePaths: Set) {
+ nodeBuilders.keys.retainAll { key -> alivePaths.any { it.startsWith(key) } }
nodeBuilders.forEach { (builderPath, builder) ->
- builder.invalidateCache(path.drop(builderPath.length - 1))
+ val drop = builderPath.length - 1
+ val childAlive = alivePaths.filter { it.startsWith(builderPath) && it.length > drop }.map { it.drop(drop) }.toSet()
+ builder.invalidateCache(childAlive)
}
}
- public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.target(schema.regions.first(), segment, rootSegmentAlias) ?: error("""internal error: no target generated for segment "${segment.id}"""")
+ public fun targetOrError(segment: Segment, rootSegmentAlias: Segment?): Path = schema.regions.firstNotNullOfOrNull { schema.target(it, segment, rootSegmentAlias) } ?: error("""internal error: no target generated for segment "${segment.id}"""")
@Suppress("UNCHECKED_CAST")
public fun payloadOrError(
@@ -78,7 +85,7 @@ public class Nbp02mainNodeBuilder(
}
public interface Factory {
- public fun createRootNode(): ParallelNode
+ public fun createRootNode(): ParallelFlowNode<*>
public fun createNbp02sheetNodeBuilder(): NodeBuilder
diff --git a/way-gradle-plugin/src/test/resources/OneChildFinishRequest.txt b/way-gradle-plugin/src/test/resources/OneChildFinishRequest.txt
new file mode 100644
index 0000000..a1f66f9
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/OneChildFinishRequest.txt
@@ -0,0 +1,9 @@
+package ru.kode.test.app.schema
+
+import ru.kode.way.Event
+
+public sealed interface OneChildFinishRequest : Event {
+ public data object Beta : OneChildFinishRequest
+
+ public data object Alpha : OneChildFinishRequest
+}
diff --git a/way-gradle-plugin/src/test/resources/OneSchema.txt b/way-gradle-plugin/src/test/resources/OneSchema.txt
new file mode 100644
index 0000000..ca51a71
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/OneSchema.txt
@@ -0,0 +1,121 @@
+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 OneSchema : Schema {
+ override val rootSegment: Segment =
+ Segment("one@TestApp:src/test/resources/schema-parallel02.dot")
+
+ override val childSchemas: Map =
+ mapOf(Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot") to AlphaSchema(), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot") to BetaSchema())
+
+ override val regions: List =
+ listOf(RegionId(Path(listOf(Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")))), RegionId(Path(listOf(Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")))))
+
+ public val alphaRegionId: RegionId
+ get() = regions[0]
+
+ public val betaRegionId: RegionId
+ get() = regions[1]
+
+ override fun target(
+ regionId: RegionId,
+ segment: Segment,
+ rootSegmentAlias: Segment?,
+ ): Path? = when (regionId) {
+ regions[0] -> {
+ val rootSegment = rootSegmentAlias ?: Segment("one@TestApp:src/test/resources/schema-parallel02.dot")
+ when(segment.id) {
+ "one@TestApp:src/test/resources/schema-parallel02.dot" -> Path(rootSegment)
+ "beta@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introb1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "alpha@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introa1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot")))
+ else -> null
+ }
+ }
+ regions[1] -> {
+ val rootSegment = rootSegmentAlias ?: Segment("one@TestApp:src/test/resources/schema-parallel02.dot")
+ when(segment.id) {
+ "one@TestApp:src/test/resources/schema-parallel02.dot" -> Path(rootSegment)
+ "beta@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introb1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "alpha@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introa1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.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("one@TestApp:src/test/resources/schema-parallel02.dot")
+ when {
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ else -> {
+ error("""internal error: no nodeType for path=$path""")
+ }
+ }
+ }
+ regions[1] -> {
+ val rootSegment = rootSegmentAlias ?: Segment("one@TestApp:src/test/resources/schema-parallel02.dot")
+ when {
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.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) {
+ Path(Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")) -> OneChildFinishRequest.Alpha
+ else -> {
+ error("""internal error: failed to build child finish event for path=$path""")
+ }
+ }
+ }
+ regions[1] -> {
+ when(path) {
+ Path(Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")) -> OneChildFinishRequest.Beta
+ 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/TestAppRegion.txt b/way-gradle-plugin/src/test/resources/TestAppRegion.txt
new file mode 100644
index 0000000..4583632
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/TestAppRegion.txt
@@ -0,0 +1,19 @@
+package ru.kode.test.app.schema
+
+import kotlin.String
+import ru.kode.way.RegionId
+
+public enum class TestAppRegion(
+ public val segmentName: String,
+) {
+ One("one"),
+ Two("two"),
+ ;
+
+ public companion object {
+ public fun forRegionId(regionId: RegionId): TestAppRegion? {
+ val name = regionId.path.segments.last().id.substringBefore('@')
+ return entries.firstOrNull { it.segmentName == name }
+ }
+ }
+}
diff --git a/way-gradle-plugin/src/test/resources/malformed-adjacent-attrs-no-comma.dot b/way-gradle-plugin/src/test/resources/malformed-adjacent-attrs-no-comma.dot
new file mode 100644
index 0000000..2a192d8
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/malformed-adjacent-attrs-no-comma.dot
@@ -0,0 +1,14 @@
+digraph MalformedAdjacentAttrsNoComma {
+ appFlow [type = "flow"]
+
+ // prsv/rosseti regression: parameterType line has NO trailing comma,
+ // resultType follows on the next line. Both must still be parsed.
+ bookFlow [
+ type = "schema"
+ parameterName = "config",
+ parameterType = "kotlin.String"
+ resultType = "kotlin.Int"
+ ]
+
+ appFlow -> bookFlow
+}
diff --git a/way-gradle-plugin/src/test/resources/malformed-duplicate-edge.dot b/way-gradle-plugin/src/test/resources/malformed-duplicate-edge.dot
new file mode 100644
index 0000000..75a884f
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/malformed-duplicate-edge.dot
@@ -0,0 +1,8 @@
+digraph MalformedDuplicateEdge {
+ // rosseti regression (main_flow.dot): the same edge declared twice.
+ appFlow [type = "flow"]
+ shutdownFlow [type = "schema"]
+
+ appFlow -> shutdownFlow
+ appFlow -> shutdownFlow
+}
diff --git a/way-gradle-plugin/src/test/resources/malformed-import-alias-mismatch-child.dot b/way-gradle-plugin/src/test/resources/malformed-import-alias-mismatch-child.dot
new file mode 100644
index 0000000..9a7afcb
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/malformed-import-alias-mismatch-child.dot
@@ -0,0 +1,14 @@
+digraph DocumentDetailsFlow {
+ package = "ru.kode.rosseti.feature.instrument.document.details.routing"
+
+ // rosseti regression: this child's ROOT node is `documentDetailsFlow`, but the
+ // importing parent (instruments_flow.dot) declares the import as
+ // `instrumentDocumentDetailsFlow [type=schema]` — a DIFFERENT alias name.
+ documentDetailsFlow [
+ type = "flow"
+ parameterName = "documentId"
+ parameterType = "kotlin.String"
+ ]
+
+ documentDetailsFlow -> main
+}
diff --git a/way-gradle-plugin/src/test/resources/malformed-param-name-no-equals.dot b/way-gradle-plugin/src/test/resources/malformed-param-name-no-equals.dot
new file mode 100644
index 0000000..2226b24
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/malformed-param-name-no-equals.dot
@@ -0,0 +1,13 @@
+digraph MalformedParamNameNoEquals {
+ appFlow [type = "flow"]
+
+ // prsv regression: `parameterName "categoryType"` has NO '=' sign.
+ // Reproduced verbatim from prsv main_flow.dot / shelf_book_categories_flow.dot.
+ categoriesFlow [
+ type = "schema"
+ parameterName "categoryType"
+ parameterType = "kotlin.String"
+ ]
+
+ appFlow -> categoriesFlow
+}
diff --git a/way-gradle-plugin/src/test/resources/malformed-result-typo.dot b/way-gradle-plugin/src/test/resources/malformed-result-typo.dot
new file mode 100644
index 0000000..d22c0b2
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/malformed-result-typo.dot
@@ -0,0 +1,9 @@
+digraph MalformedResultTypo {
+ // rosseti regression (mapaddress_flow.dot): `result` used instead of `resultType`.
+ mapAddressFlow [
+ type = "flow",
+ result = "com.example.MapAddressFlow.Result"
+ ]
+
+ mapAddressFlow -> picker
+}
diff --git a/way-gradle-plugin/src/test/resources/node-builders-parallel01.dot b/way-gradle-plugin/src/test/resources/node-builders-parallel01.dot
index b00ab30..79bdf2f 100644
--- a/way-gradle-plugin/src/test/resources/node-builders-parallel01.dot
+++ b/way-gradle-plugin/src/test/resources/node-builders-parallel01.dot
@@ -5,7 +5,7 @@ digraph NodeBuildersParallel01 {
nbp01head [type=flow]
nbp01sheet [type=flow]
- nbp01main [type=parallel]
+ nbp01main [type=parallelFlow]
nbp01main -> nbp01head
nbp01main -> nbp01sheet
}
diff --git a/way-gradle-plugin/src/test/resources/node-builders-parallel02.dot b/way-gradle-plugin/src/test/resources/node-builders-parallel02.dot
index 903591d..1114818 100644
--- a/way-gradle-plugin/src/test/resources/node-builders-parallel02.dot
+++ b/way-gradle-plugin/src/test/resources/node-builders-parallel02.dot
@@ -5,7 +5,7 @@ digraph NodeBuildersParallel02 {
nbp02head [type=schema]
nbp02sheet [type=schema]
- nbp02main [type=parallel]
+ nbp02main [type=parallelFlow]
nbp02main -> nbp02head
nbp02main -> nbp02sheet
}
diff --git a/way-gradle-plugin/src/test/resources/schema-composition01.txt b/way-gradle-plugin/src/test/resources/schema-composition01.txt
index ffafdd4..0d607c3 100644
--- a/way-gradle-plugin/src/test/resources/schema-composition01.txt
+++ b/way-gradle-plugin/src/test/resources/schema-composition01.txt
@@ -14,13 +14,17 @@ public class TestAppSchema(
private val loginSchema: Schema,
private val mainSchema: Schema,
) : Schema {
- override val rootSegment: Segment = Segment("app@src/test/resources/schema-composition01.dot")
+ override val rootSegment: Segment =
+ Segment("app@TestApp:src/test/resources/schema-composition01.dot")
override val childSchemas: Map =
- mapOf(Segment("app@src/test/resources/schema-composition01.dot") to appSchema, Segment("login@src/test/resources/schema-composition01.dot") to loginSchema, Segment("main@src/test/resources/schema-composition01.dot") to mainSchema)
+ mapOf(Segment("app@TestApp:src/test/resources/schema-composition01.dot") to appSchema, Segment("login@TestApp:src/test/resources/schema-composition01.dot") to loginSchema, Segment("main@TestApp:src/test/resources/schema-composition01.dot") to mainSchema)
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("app@src/test/resources/schema-composition01.dot")))))
+ listOf(RegionId(Path(listOf(Segment("app@TestApp:src/test/resources/schema-composition01.dot")))))
+
+ public val appRegionId: RegionId
+ get() = regions[0]
override fun target(
regionId: RegionId,
@@ -28,11 +32,11 @@ public class TestAppSchema(
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/schema-composition01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/schema-composition01.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "login@src/test/resources/schema-composition01.dot" -> Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot")))
- "main@src/test/resources/schema-composition01.dot" -> Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot"), Segment("main@src/test/resources/schema-composition01.dot")))
+ "app@TestApp:src/test/resources/schema-composition01.dot" -> Path(rootSegment)
+ "login@TestApp:src/test/resources/schema-composition01.dot" -> Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot")))
+ "main@TestApp:src/test/resources/schema-composition01.dot" -> Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot"), Segment("main@TestApp:src/test/resources/schema-composition01.dot")))
else -> null
}
}
@@ -47,11 +51,11 @@ public class TestAppSchema(
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/schema-composition01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/schema-composition01.dot")
when {
path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot"))) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot"), Segment("main@src/test/resources/schema-composition01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot"), Segment("main@TestApp:src/test/resources/schema-composition01.dot"))) -> Schema.NodeType.Flow
else -> {
error("""internal error: no nodeType for path=$path""")
}
@@ -69,8 +73,8 @@ public class TestAppSchema(
): Event = when (regionId) {
regions[0] -> {
when(path) {
- Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot"))) -> AppChildFinishRequest.Login
- Path(listOf(rootSegment, Segment("login@src/test/resources/schema-composition01.dot"), Segment("main@src/test/resources/schema-composition01.dot"))) -> AppChildFinishRequest.Main
+ Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot"))) -> AppChildFinishRequest.Login
+ Path(listOf(rootSegment, Segment("login@TestApp:src/test/resources/schema-composition01.dot"), Segment("main@TestApp:src/test/resources/schema-composition01.dot"))) -> AppChildFinishRequest.Main
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/schema-composition02.txt b/way-gradle-plugin/src/test/resources/schema-composition02.txt
index 5382007..e2d07d2 100644
--- a/way-gradle-plugin/src/test/resources/schema-composition02.txt
+++ b/way-gradle-plugin/src/test/resources/schema-composition02.txt
@@ -13,13 +13,17 @@ public class TestAppSchema(
private val loginSchema: Schema,
private val mainSchema: Schema,
) : Schema {
- override val rootSegment: Segment = Segment("app@src/test/resources/schema-composition02.dot")
+ override val rootSegment: Segment =
+ Segment("app@TestApp:src/test/resources/schema-composition02.dot")
override val childSchemas: Map =
- mapOf(Segment("login@src/test/resources/schema-composition02.dot") to loginSchema, Segment("main@src/test/resources/schema-composition02.dot") to mainSchema)
+ mapOf(Segment("login@TestApp:src/test/resources/schema-composition02.dot") to loginSchema, Segment("main@TestApp:src/test/resources/schema-composition02.dot") to mainSchema)
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("app@src/test/resources/schema-composition02.dot")))))
+ listOf(RegionId(Path(listOf(Segment("app@TestApp:src/test/resources/schema-composition02.dot")))))
+
+ public val appRegionId: RegionId
+ get() = regions[0]
override fun target(
regionId: RegionId,
@@ -27,13 +31,13 @@ public class TestAppSchema(
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/schema-composition02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/schema-composition02.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "page1@src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot")))
- "page2@src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot")))
- "login@src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot")))
- "main@src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot"), Segment("main@src/test/resources/schema-composition02.dot")))
+ "app@TestApp:src/test/resources/schema-composition02.dot" -> Path(rootSegment)
+ "page1@TestApp:src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot")))
+ "page2@TestApp:src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot")))
+ "login@TestApp:src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot")))
+ "main@TestApp:src/test/resources/schema-composition02.dot" -> Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot"), Segment("main@TestApp:src/test/resources/schema-composition02.dot")))
else -> null
}
}
@@ -48,13 +52,13 @@ public class TestAppSchema(
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/schema-composition02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/schema-composition02.dot")
when {
path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot"), Segment("main@src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot"), Segment("main@TestApp:src/test/resources/schema-composition02.dot"))) -> Schema.NodeType.Flow
else -> {
error("""internal error: no nodeType for path=$path""")
}
@@ -72,8 +76,8 @@ public class TestAppSchema(
): Event = when (regionId) {
regions[0] -> {
when(path) {
- Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot"))) -> AppChildFinishRequest.Login
- Path(listOf(rootSegment, Segment("page1@src/test/resources/schema-composition02.dot"), Segment("page2@src/test/resources/schema-composition02.dot"), Segment("login@src/test/resources/schema-composition02.dot"), Segment("main@src/test/resources/schema-composition02.dot"))) -> AppChildFinishRequest.Main
+ Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot"))) -> AppChildFinishRequest.Login
+ Path(listOf(rootSegment, Segment("page1@TestApp:src/test/resources/schema-composition02.dot"), Segment("page2@TestApp:src/test/resources/schema-composition02.dot"), Segment("login@TestApp:src/test/resources/schema-composition02.dot"), Segment("main@TestApp:src/test/resources/schema-composition02.dot"))) -> AppChildFinishRequest.Main
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/schema-parallel01.dot b/way-gradle-plugin/src/test/resources/schema-parallel01.dot
index fb331df..a3744c7 100644
--- a/way-gradle-plugin/src/test/resources/schema-parallel01.dot
+++ b/way-gradle-plugin/src/test/resources/schema-parallel01.dot
@@ -1,7 +1,7 @@
digraph TestApp {
schemaFileName = "schema-parallel01"
- main [type = parallel]
+ main [type = parallelFlow]
one [type = flow]
two [type = flow]
diff --git a/way-gradle-plugin/src/test/resources/schema-parallel01.txt b/way-gradle-plugin/src/test/resources/schema-parallel01.txt
index 35c3ce9..0e016f9 100644
--- a/way-gradle-plugin/src/test/resources/schema-parallel01.txt
+++ b/way-gradle-plugin/src/test/resources/schema-parallel01.txt
@@ -10,12 +10,20 @@ import ru.kode.way.Schema
import ru.kode.way.Segment
public class TestAppSchema : Schema {
- override val rootSegment: Segment = Segment("main@src/test/resources/schema-parallel01.dot")
+ override val rootSegment: Segment =
+ Segment("main@TestApp:src/test/resources/schema-parallel01.dot")
- override val childSchemas: Map = emptyMap()
+ override val childSchemas: Map =
+ mapOf(Segment("one@TestApp:src/test/resources/schema-parallel01.dot") to OneSchema(), Segment("two@TestApp:src/test/resources/schema-parallel01.dot") to TwoSchema())
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel01.dot"), Segment("one@src/test/resources/schema-parallel01.dot")))), RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel01.dot"), Segment("two@src/test/resources/schema-parallel01.dot")))))
+ listOf(RegionId(Path(listOf(Segment("main@TestApp:src/test/resources/schema-parallel01.dot"), Segment("one@TestApp:src/test/resources/schema-parallel01.dot")))), RegionId(Path(listOf(Segment("main@TestApp:src/test/resources/schema-parallel01.dot"), Segment("two@TestApp:src/test/resources/schema-parallel01.dot")))))
+
+ public val oneRegionId: RegionId
+ get() = regions[0]
+
+ public val twoRegionId: RegionId
+ get() = regions[1]
override fun target(
regionId: RegionId,
@@ -23,19 +31,26 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("one@src/test/resources/schema-parallel01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel01.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "intro1@src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel01.dot"), Segment("intro1@src/test/resources/schema-parallel01.dot")))
- "intro11@src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel01.dot"), Segment("intro1@src/test/resources/schema-parallel01.dot"), Segment("intro11@src/test/resources/schema-parallel01.dot")))
+ "main@TestApp:src/test/resources/schema-parallel01.dot" -> Path(rootSegment)
+ "two@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro2@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro2@TestApp:src/test/resources/schema-parallel01.dot")))
+ "one@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro1@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro11@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro11@TestApp:src/test/resources/schema-parallel01.dot")))
else -> null
}
}
regions[1] -> {
- val rootSegment = rootSegmentAlias ?: Segment("two@src/test/resources/schema-parallel01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel01.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "intro2@src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("two@src/test/resources/schema-parallel01.dot"), Segment("intro2@src/test/resources/schema-parallel01.dot")))
+ "main@TestApp:src/test/resources/schema-parallel01.dot" -> Path(rootSegment)
+ "two@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro2@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro2@TestApp:src/test/resources/schema-parallel01.dot")))
+ "one@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro1@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot")))
+ "intro11@TestApp:src/test/resources/schema-parallel01.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro11@TestApp:src/test/resources/schema-parallel01.dot")))
else -> null
}
}
@@ -50,21 +65,28 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("one@src/test/resources/schema-parallel01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel01.dot")
when {
- path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel01.dot"), Segment("intro1@src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel01.dot"), Segment("intro1@src/test/resources/schema-parallel01.dot"), Segment("intro11@src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro2@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro11@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
}
}
regions[1] -> {
- val rootSegment = rootSegmentAlias ?: Segment("two@src/test/resources/schema-parallel01.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel01.dot")
when {
- path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("two@src/test/resources/schema-parallel01.dot"), Segment("intro2@src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro2@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel01.dot"), Segment("intro11@TestApp:src/test/resources/schema-parallel01.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
@@ -82,6 +104,7 @@ public class TestAppSchema : Schema {
): Event = when (regionId) {
regions[0] -> {
when(path) {
+ Path(Segment("one@TestApp:src/test/resources/schema-parallel01.dot")) -> MainChildFinishRequest.One
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
@@ -89,6 +112,7 @@ public class TestAppSchema : Schema {
}
regions[1] -> {
when(path) {
+ Path(Segment("two@TestApp:src/test/resources/schema-parallel01.dot")) -> MainChildFinishRequest.Two
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/schema-parallel02.dot b/way-gradle-plugin/src/test/resources/schema-parallel02.dot
index 34ea290..4cb08d3 100644
--- a/way-gradle-plugin/src/test/resources/schema-parallel02.dot
+++ b/way-gradle-plugin/src/test/resources/schema-parallel02.dot
@@ -3,9 +3,9 @@ digraph TestApp {
// Multiple parallel nodes in one file
- main [type = parallel]
+ main [type = parallelFlow]
- one [type = parallel]
+ one [type = parallelFlow]
two [type = flow]
alpha [type = flow]
beta [type = flow]
diff --git a/way-gradle-plugin/src/test/resources/schema-parallel02.txt b/way-gradle-plugin/src/test/resources/schema-parallel02.txt
index e4d4c35..c985f58 100644
--- a/way-gradle-plugin/src/test/resources/schema-parallel02.txt
+++ b/way-gradle-plugin/src/test/resources/schema-parallel02.txt
@@ -10,12 +10,20 @@ import ru.kode.way.Schema
import ru.kode.way.Segment
public class TestAppSchema : Schema {
- override val rootSegment: Segment = Segment("main@src/test/resources/schema-parallel02.dot")
+ override val rootSegment: Segment =
+ Segment("main@TestApp:src/test/resources/schema-parallel02.dot")
- override val childSchemas: Map = emptyMap()
+ override val childSchemas: Map =
+ mapOf(Segment("one@TestApp:src/test/resources/schema-parallel02.dot") to OneSchema(), Segment("two@TestApp:src/test/resources/schema-parallel02.dot") to TwoSchema(), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot") to AlphaSchema(), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot") to BetaSchema())
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel02.dot"), Segment("one@src/test/resources/schema-parallel02.dot")))), RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel02.dot"), Segment("two@src/test/resources/schema-parallel02.dot")))), RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel02.dot"), Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot")))), RegionId(Path(listOf(Segment("main@src/test/resources/schema-parallel02.dot"), Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot")))))
+ listOf(RegionId(Path(listOf(Segment("main@TestApp:src/test/resources/schema-parallel02.dot"), Segment("one@TestApp:src/test/resources/schema-parallel02.dot")))), RegionId(Path(listOf(Segment("main@TestApp:src/test/resources/schema-parallel02.dot"), Segment("two@TestApp:src/test/resources/schema-parallel02.dot")))))
+
+ public val oneRegionId: RegionId
+ get() = regions[0]
+
+ public val twoRegionId: RegionId
+ get() = regions[1]
override fun target(
regionId: RegionId,
@@ -23,37 +31,30 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("one@src/test/resources/schema-parallel02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel02.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "beta@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot")))
- "introb1@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"), Segment("introb1@src/test/resources/schema-parallel02.dot")))
- "alpha@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot")))
- "introa1@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"), Segment("introa1@src/test/resources/schema-parallel02.dot")))
+ "main@TestApp:src/test/resources/schema-parallel02.dot" -> Path(rootSegment)
+ "two@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot")))
+ "intro1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "one@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot")))
+ "beta@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introb1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "alpha@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introa1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot")))
else -> null
}
}
regions[1] -> {
- val rootSegment = rootSegmentAlias ?: Segment("two@src/test/resources/schema-parallel02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel02.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "intro1@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("two@src/test/resources/schema-parallel02.dot"), Segment("intro1@src/test/resources/schema-parallel02.dot")))
- else -> null
- }
- }
- regions[2] -> {
- val rootSegment = rootSegmentAlias ?: Segment("alpha@src/test/resources/schema-parallel02.dot")
- when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "introa1@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"), Segment("introa1@src/test/resources/schema-parallel02.dot")))
- else -> null
- }
- }
- regions[3] -> {
- val rootSegment = rootSegmentAlias ?: Segment("beta@src/test/resources/schema-parallel02.dot")
- when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "introb1@src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"), Segment("introb1@src/test/resources/schema-parallel02.dot")))
+ "main@TestApp:src/test/resources/schema-parallel02.dot" -> Path(rootSegment)
+ "two@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot")))
+ "intro1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "one@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot")))
+ "beta@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introb1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot")))
+ "alpha@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot")))
+ "introa1@TestApp:src/test/resources/schema-parallel02.dot" -> Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot")))
else -> null
}
}
@@ -68,43 +69,32 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("one@src/test/resources/schema-parallel02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel02.dot")
when {
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Parallel
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"), Segment("introb1@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"), Segment("introa1@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
}
}
regions[1] -> {
- val rootSegment = rootSegmentAlias ?: Segment("two@src/test/resources/schema-parallel02.dot")
- when {
- path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("two@src/test/resources/schema-parallel02.dot"), Segment("intro1@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
- else -> {
- error("""internal error: no nodeType for path=$path""")
- }
- }
- }
- regions[2] -> {
- val rootSegment = rootSegmentAlias ?: Segment("alpha@src/test/resources/schema-parallel02.dot")
- when {
- path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"), Segment("introa1@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
- else -> {
- error("""internal error: no nodeType for path=$path""")
- }
- }
- }
- regions[3] -> {
- val rootSegment = rootSegmentAlias ?: Segment("beta@src/test/resources/schema-parallel02.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("main@TestApp:src/test/resources/schema-parallel02.dot")
when {
- path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"), Segment("introb1@src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(rootSegment) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("two@TestApp:src/test/resources/schema-parallel02.dot"), Segment("intro1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.ParallelFlow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introb1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Flow
+ path == Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"), Segment("introa1@TestApp:src/test/resources/schema-parallel02.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
@@ -122,8 +112,9 @@ public class TestAppSchema : Schema {
): Event = when (regionId) {
regions[0] -> {
when(path) {
- Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("beta@src/test/resources/schema-parallel02.dot"))) -> OneChildFinishRequest.Beta
- Path(listOf(rootSegment, Segment("one@src/test/resources/schema-parallel02.dot"), Segment("alpha@src/test/resources/schema-parallel02.dot"))) -> OneChildFinishRequest.Alpha
+ Path(Segment("one@TestApp:src/test/resources/schema-parallel02.dot")) -> MainChildFinishRequest.One
+ Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("beta@TestApp:src/test/resources/schema-parallel02.dot"))) -> OneChildFinishRequest.Beta
+ Path(listOf(rootSegment, Segment("one@TestApp:src/test/resources/schema-parallel02.dot"), Segment("alpha@TestApp:src/test/resources/schema-parallel02.dot"))) -> OneChildFinishRequest.Alpha
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
@@ -131,20 +122,7 @@ public class TestAppSchema : Schema {
}
regions[1] -> {
when(path) {
- else -> {
- error("""internal error: failed to build child finish event for path=$path""")
- }
- }
- }
- regions[2] -> {
- when(path) {
- else -> {
- error("""internal error: failed to build child finish event for path=$path""")
- }
- }
- }
- regions[3] -> {
- when(path) {
+ Path(Segment("two@TestApp:src/test/resources/schema-parallel02.dot")) -> MainChildFinishRequest.Two
else -> {
error("""internal error: failed to build child finish event for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/schema-parallel03.dot b/way-gradle-plugin/src/test/resources/schema-parallel03.dot
new file mode 100644
index 0000000..627fa26
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/schema-parallel03.dot
@@ -0,0 +1,16 @@
+digraph TestApp {
+ schemaFileName = "schema-parallel03"
+
+ // Nested parallel: main -> one[parallel] -> alpha[flow] -> alphaSub[flow] -> screen
+ // plus a sibling beta[flow] under one for completeness.
+
+ mainParallel03 [type = parallelFlow]
+
+ one [type = parallelFlow]
+ alpha [type = flow, resultType = "kotlin.String"]
+ alphaSub [type = flow]
+ beta [type = flow]
+
+ mainParallel03 -> one -> alpha -> alphaSub -> alphaSubScreen
+ one -> beta -> betaScreen
+}
diff --git a/way-gradle-plugin/src/test/resources/single-flow-attr-order-schema.txt b/way-gradle-plugin/src/test/resources/single-flow-attr-order-schema.txt
index 5013f90..0f607e2 100644
--- a/way-gradle-plugin/src/test/resources/single-flow-attr-order-schema.txt
+++ b/way-gradle-plugin/src/test/resources/single-flow-attr-order-schema.txt
@@ -10,12 +10,16 @@ import ru.kode.way.Schema
import ru.kode.way.Segment
public class TestAppSchema : Schema {
- override val rootSegment: Segment = Segment("app@src/test/resources/single-flow-attr-order.dot")
+ override val rootSegment: Segment =
+ Segment("app@TestApp:src/test/resources/single-flow-attr-order.dot")
override val childSchemas: Map = emptyMap()
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("app@src/test/resources/single-flow-attr-order.dot")))))
+ listOf(RegionId(Path(listOf(Segment("app@TestApp:src/test/resources/single-flow-attr-order.dot")))))
+
+ public val appRegionId: RegionId
+ get() = regions[0]
override fun target(
regionId: RegionId,
@@ -23,12 +27,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/single-flow-attr-order.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/single-flow-attr-order.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "screen1@src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot")))
- "screen2@src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot"), Segment("screen2@src/test/resources/single-flow-attr-order.dot")))
- "screen3@src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot"), Segment("screen2@src/test/resources/single-flow-attr-order.dot"), Segment("screen3@src/test/resources/single-flow-attr-order.dot")))
+ "app@TestApp:src/test/resources/single-flow-attr-order.dot" -> Path(rootSegment)
+ "screen1@TestApp:src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot")))
+ "screen2@TestApp:src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-attr-order.dot")))
+ "screen3@TestApp:src/test/resources/single-flow-attr-order.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen3@TestApp:src/test/resources/single-flow-attr-order.dot")))
else -> null
}
}
@@ -43,12 +47,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/single-flow-attr-order.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/single-flow-attr-order.dot")
when {
path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot"), Segment("screen2@src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-attr-order.dot"), Segment("screen2@src/test/resources/single-flow-attr-order.dot"), Segment("screen3@src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-attr-order.dot"), Segment("screen3@TestApp:src/test/resources/single-flow-attr-order.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/single-flow-non-app-root-schema.txt b/way-gradle-plugin/src/test/resources/single-flow-non-app-root-schema.txt
index cf7b088..c82bd58 100644
--- a/way-gradle-plugin/src/test/resources/single-flow-non-app-root-schema.txt
+++ b/way-gradle-plugin/src/test/resources/single-flow-non-app-root-schema.txt
@@ -11,12 +11,15 @@ import ru.kode.way.Segment
public class TestAppSchema : Schema {
override val rootSegment: Segment =
- Segment("permissions@src/test/resources/single-flow-non-app-root.dot")
+ Segment("permissions@TestApp:src/test/resources/single-flow-non-app-root.dot")
override val childSchemas: Map = emptyMap()
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("permissions@src/test/resources/single-flow-non-app-root.dot")))))
+ listOf(RegionId(Path(listOf(Segment("permissions@TestApp:src/test/resources/single-flow-non-app-root.dot")))))
+
+ public val permissionsRegionId: RegionId
+ get() = regions[0]
override fun target(
regionId: RegionId,
@@ -24,12 +27,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("permissions@src/test/resources/single-flow-non-app-root.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("permissions@TestApp:src/test/resources/single-flow-non-app-root.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "screen1@src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot")))
- "screen2@src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@src/test/resources/single-flow-non-app-root.dot")))
- "screen3@src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@src/test/resources/single-flow-non-app-root.dot"), Segment("screen3@src/test/resources/single-flow-non-app-root.dot")))
+ "permissions@TestApp:src/test/resources/single-flow-non-app-root.dot" -> Path(rootSegment)
+ "screen1@TestApp:src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot")))
+ "screen2@TestApp:src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-non-app-root.dot")))
+ "screen3@TestApp:src/test/resources/single-flow-non-app-root.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen3@TestApp:src/test/resources/single-flow-non-app-root.dot")))
else -> null
}
}
@@ -44,12 +47,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("permissions@src/test/resources/single-flow-non-app-root.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("permissions@TestApp:src/test/resources/single-flow-non-app-root.dot")
when {
path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@src/test/resources/single-flow-non-app-root.dot"), Segment("screen3@src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen2@TestApp:src/test/resources/single-flow-non-app-root.dot"), Segment("screen3@TestApp:src/test/resources/single-flow-non-app-root.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/single-flow-schema.txt b/way-gradle-plugin/src/test/resources/single-flow-schema.txt
index 4502a7b..3a04389 100644
--- a/way-gradle-plugin/src/test/resources/single-flow-schema.txt
+++ b/way-gradle-plugin/src/test/resources/single-flow-schema.txt
@@ -10,12 +10,15 @@ import ru.kode.way.Schema
import ru.kode.way.Segment
public class TestAppSchema : Schema {
- override val rootSegment: Segment = Segment("app@src/test/resources/single-flow.dot")
+ override val rootSegment: Segment = Segment("app@TestApp:src/test/resources/single-flow.dot")
override val childSchemas: Map = emptyMap()
override val regions: List =
- listOf(RegionId(Path(listOf(Segment("app@src/test/resources/single-flow.dot")))))
+ listOf(RegionId(Path(listOf(Segment("app@TestApp:src/test/resources/single-flow.dot")))))
+
+ public val appRegionId: RegionId
+ get() = regions[0]
override fun target(
regionId: RegionId,
@@ -23,12 +26,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Path? = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/single-flow.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/single-flow.dot")
when(segment.id) {
- rootSegment.id -> Path(rootSegment)
- "screen1@src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot")))
- "screen2@src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot"), Segment("screen2@src/test/resources/single-flow.dot")))
- "screen3@src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot"), Segment("screen2@src/test/resources/single-flow.dot"), Segment("screen3@src/test/resources/single-flow.dot")))
+ "app@TestApp:src/test/resources/single-flow.dot" -> Path(rootSegment)
+ "screen1@TestApp:src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot")))
+ "screen2@TestApp:src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot"), Segment("screen2@TestApp:src/test/resources/single-flow.dot")))
+ "screen3@TestApp:src/test/resources/single-flow.dot" -> Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot"), Segment("screen2@TestApp:src/test/resources/single-flow.dot"), Segment("screen3@TestApp:src/test/resources/single-flow.dot")))
else -> null
}
}
@@ -43,12 +46,12 @@ public class TestAppSchema : Schema {
rootSegmentAlias: Segment?,
): Schema.NodeType = when (regionId) {
regions[0] -> {
- val rootSegment = rootSegmentAlias ?: Segment("app@src/test/resources/single-flow.dot")
+ val rootSegment = rootSegmentAlias ?: Segment("app@TestApp:src/test/resources/single-flow.dot")
when {
path == Path(rootSegment) -> Schema.NodeType.Flow
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot"), Segment("screen2@src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
- path == Path(listOf(rootSegment, Segment("screen1@src/test/resources/single-flow.dot"), Segment("screen2@src/test/resources/single-flow.dot"), Segment("screen3@src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot"), Segment("screen2@TestApp:src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
+ path == Path(listOf(rootSegment, Segment("screen1@TestApp:src/test/resources/single-flow.dot"), Segment("screen2@TestApp:src/test/resources/single-flow.dot"), Segment("screen3@TestApp:src/test/resources/single-flow.dot"))) -> Schema.NodeType.Screen
else -> {
error("""internal error: no nodeType for path=$path""")
}
diff --git a/way-gradle-plugin/src/test/resources/targets-test01-targets.txt b/way-gradle-plugin/src/test/resources/targets-test01-targets.txt
index feda5c7..c009fc2 100644
--- a/way-gradle-plugin/src/test/resources/targets-test01-targets.txt
+++ b/way-gradle-plugin/src/test/resources/targets-test01-targets.txt
@@ -11,19 +11,19 @@ public class AppTargets(
private val prefix: Path? = null,
) {
public val permissions: FlowTarget =
- FlowTarget(flowPath(Path(listOf(Segment("permissions@src/test/resources/targets-test01.dot")))))
+ FlowTarget(flowPath(Path(listOf(Segment("permissions@TestApp:src/test/resources/targets-test01.dot")))))
public val screen4: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("screen4@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("screen4@TestApp:src/test/resources/targets-test01.dot")))))
public val screen1: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("screen1@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("screen1@TestApp:src/test/resources/targets-test01.dot")))))
public val screen2: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("screen1@src/test/resources/targets-test01.dot"), Segment("screen2@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("screen1@TestApp:src/test/resources/targets-test01.dot"), Segment("screen2@TestApp:src/test/resources/targets-test01.dot")))))
public val screen3: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("screen1@src/test/resources/targets-test01.dot"), Segment("screen2@src/test/resources/targets-test01.dot"), Segment("screen3@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("screen1@TestApp:src/test/resources/targets-test01.dot"), Segment("screen2@TestApp:src/test/resources/targets-test01.dot"), Segment("screen3@TestApp:src/test/resources/targets-test01.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
@@ -32,13 +32,13 @@ public class PermissionsTargets(
private val prefix: Path? = null,
) {
public val finish: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("finish@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("finish@TestApp:src/test/resources/targets-test01.dot")))))
public val intro: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("intro@TestApp:src/test/resources/targets-test01.dot")))))
public val page1: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test01.dot"), Segment("page1@src/test/resources/targets-test01.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("intro@TestApp:src/test/resources/targets-test01.dot"), Segment("page1@TestApp:src/test/resources/targets-test01.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
diff --git a/way-gradle-plugin/src/test/resources/targets-test02-targets.txt b/way-gradle-plugin/src/test/resources/targets-test02-targets.txt
index 73bd825..31223f6 100644
--- a/way-gradle-plugin/src/test/resources/targets-test02-targets.txt
+++ b/way-gradle-plugin/src/test/resources/targets-test02-targets.txt
@@ -11,10 +11,10 @@ public class AppTargets(
private val prefix: Path? = null,
) {
public val login: FlowTarget =
- FlowTarget(flowPath(Path(listOf(Segment("login@src/test/resources/targets-test02.dot")))))
+ FlowTarget(flowPath(Path(listOf(Segment("login@TargetsTest02:src/test/resources/targets-test02.dot")))))
public val onboarding: FlowTarget =
- FlowTarget(flowPath(Path(listOf(Segment("login@src/test/resources/targets-test02.dot"), Segment("credentials@src/test/resources/targets-test02.dot"), Segment("onboarding@src/test/resources/targets-test02.dot")))))
+ FlowTarget(flowPath(Path(listOf(Segment("login@TargetsTest02:src/test/resources/targets-test02.dot"), Segment("credentials@TargetsTest02:src/test/resources/targets-test02.dot"), Segment("onboarding@TargetsTest02:src/test/resources/targets-test02.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
@@ -23,7 +23,7 @@ public class LoginTargets(
private val prefix: Path? = null,
) {
public val credentials: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("credentials@src/test/resources/targets-test02.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("credentials@TargetsTest02:src/test/resources/targets-test02.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
@@ -32,7 +32,7 @@ public class OnboardingTargets(
private val prefix: Path? = null,
) {
public val intro: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test02.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("intro@TargetsTest02:src/test/resources/targets-test02.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
diff --git a/way-gradle-plugin/src/test/resources/targets-test03-targets.txt b/way-gradle-plugin/src/test/resources/targets-test03-targets.txt
index a4e36ae..6419a55 100644
--- a/way-gradle-plugin/src/test/resources/targets-test03-targets.txt
+++ b/way-gradle-plugin/src/test/resources/targets-test03-targets.txt
@@ -11,10 +11,10 @@ public class AppTargets(
private val prefix: Path? = null,
) {
public val intro: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test03.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("intro@TargetsTest03:src/test/resources/targets-test03.dot")))))
public val permissions: FlowTarget =
- FlowTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test03.dot"), Segment("permissions@src/test/resources/targets-test03.dot")))))
+ FlowTarget(flowPath(Path(listOf(Segment("intro@TargetsTest03:src/test/resources/targets-test03.dot"), Segment("permissions@TargetsTest03:src/test/resources/targets-test03.dot")))))
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
diff --git a/way-gradle-plugin/src/test/resources/targets-test04-targets.txt b/way-gradle-plugin/src/test/resources/targets-test04-targets.txt
index c5b5c98..11f751e 100644
--- a/way-gradle-plugin/src/test/resources/targets-test04-targets.txt
+++ b/way-gradle-plugin/src/test/resources/targets-test04-targets.txt
@@ -14,15 +14,15 @@ public class AppTargets(
private val prefix: Path? = null,
) {
public val intro: ScreenTarget =
- ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test04.dot")))))
+ ScreenTarget(flowPath(Path(listOf(Segment("intro@TargetsTest04:src/test/resources/targets-test04.dot")))))
- public fun main(userId: Int): FlowTarget = FlowTarget(flowPath(Path(listOf(Segment("main@src/test/resources/targets-test04.dot")))), payload = userId)
+ public fun main(userId: Int): FlowTarget = FlowTarget(flowPath(Path(listOf(Segment("main@TargetsTest04:src/test/resources/targets-test04.dot")))), payload = userId)
- public fun page1(charset: Charset): ScreenTarget = ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test04.dot"), Segment("page1@src/test/resources/targets-test04.dot")))), payload = charset)
+ public fun page1(charset: Charset): ScreenTarget = ScreenTarget(flowPath(Path(listOf(Segment("intro@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("page1@TargetsTest04:src/test/resources/targets-test04.dot")))), payload = charset)
- public fun page2(userCount: Int): ScreenTarget = ScreenTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test04.dot"), Segment("page1@src/test/resources/targets-test04.dot"), Segment("page2@src/test/resources/targets-test04.dot")))), payload = userCount)
+ public fun page2(userCount: Int): ScreenTarget = ScreenTarget(flowPath(Path(listOf(Segment("intro@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("page1@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("page2@TargetsTest04:src/test/resources/targets-test04.dot")))), payload = userCount)
- public fun permissions(requireGrantAll: Boolean): FlowTarget = FlowTarget(flowPath(Path(listOf(Segment("intro@src/test/resources/targets-test04.dot"), Segment("page1@src/test/resources/targets-test04.dot"), Segment("page2@src/test/resources/targets-test04.dot"), Segment("permissions@src/test/resources/targets-test04.dot")))), payload = requireGrantAll)
+ public fun permissions(requireGrantAll: Boolean): FlowTarget = FlowTarget(flowPath(Path(listOf(Segment("intro@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("page1@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("page2@TargetsTest04:src/test/resources/targets-test04.dot"), Segment("permissions@TargetsTest04:src/test/resources/targets-test04.dot")))), payload = requireGrantAll)
private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
}
diff --git a/way-gradle-plugin/src/test/resources/targets-test05-targets.txt b/way-gradle-plugin/src/test/resources/targets-test05-targets.txt
new file mode 100644
index 0000000..0bdce33
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test05-targets.txt
@@ -0,0 +1,31 @@
+package ru.kode.test.app.schema
+
+import kotlin.String
+import ru.kode.way.FlowTarget
+import ru.kode.way.Path
+import ru.kode.way.ScreenTarget
+import ru.kode.way.Segment
+import ru.kode.way.Target
+import ru.kode.way.append
+
+public class AppTargets(
+ private val prefix: Path? = null,
+) {
+ public fun child(token: String?): FlowTarget = FlowTarget(flowPath(Path(listOf(Segment("child@TargetsTest05:src/test/resources/targets-test05.dot")))), payload = token)
+
+ private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
+}
+
+public class ChildTargets(
+ private val prefix: Path? = null,
+) {
+ public fun screen(note: String?): ScreenTarget = ScreenTarget(flowPath(Path(listOf(Segment("screen@TargetsTest05:src/test/resources/targets-test05.dot")))), payload = note)
+
+ private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
+}
+
+public val Target.Companion.app: AppTargets
+ get() = AppTargets()
+
+public val Target.Companion.child: ChildTargets
+ get() = ChildTargets()
diff --git a/way-gradle-plugin/src/test/resources/targets-test05.dot b/way-gradle-plugin/src/test/resources/targets-test05.dot
new file mode 100644
index 0000000..3c482cf
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test05.dot
@@ -0,0 +1,21 @@
+// nullable parameter and result types
+digraph TargetsTest05 {
+ schemaFileName = "targets-test05-schema"
+ targetsFileName = "targets-test05-targets"
+
+ app [type = flow]
+
+ child [
+ type = flow,
+ resultType = "kotlin.String?",
+ parameterName = "token",
+ parameterType = "kotlin.String?"
+ ]
+
+ screen [
+ parameterName = "note",
+ parameterType = "kotlin.String?"
+ ]
+
+ app -> child -> screen
+}
diff --git a/way-gradle-plugin/src/test/resources/targets-test06-targets.txt b/way-gradle-plugin/src/test/resources/targets-test06-targets.txt
new file mode 100644
index 0000000..0bea597
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test06-targets.txt
@@ -0,0 +1,42 @@
+package ru.kode.test.app.schema
+
+import ru.kode.way.FlowTarget
+import ru.kode.way.HistoryTarget
+import ru.kode.way.Path
+import ru.kode.way.ScreenTarget
+import ru.kode.way.Segment
+import ru.kode.way.Target
+import ru.kode.way.append
+
+public class AppTargets(
+ private val prefix: Path? = null,
+) {
+ public val main: FlowTarget =
+ FlowTarget(flowPath(Path(listOf(Segment("main@TargetsTest06:src/test/resources/targets-test06.dot")))))
+
+ private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
+}
+
+public class MainTargets(
+ private val prefix: Path? = null,
+) {
+ public val mainDeepHist: HistoryTarget =
+ HistoryTarget(flowPath(Path(listOf(Segment("app@TargetsTest06:src/test/resources/targets-test06.dot"), Segment("main@TargetsTest06:src/test/resources/targets-test06.dot")))), deep = true)
+
+ public val mainHist: HistoryTarget =
+ HistoryTarget(flowPath(Path(listOf(Segment("app@TargetsTest06:src/test/resources/targets-test06.dot"), Segment("main@TargetsTest06:src/test/resources/targets-test06.dot")))), deep = false)
+
+ public val screenB: ScreenTarget =
+ ScreenTarget(flowPath(Path(listOf(Segment("screenB@TargetsTest06:src/test/resources/targets-test06.dot")))))
+
+ public val screenA: ScreenTarget =
+ ScreenTarget(flowPath(Path(listOf(Segment("screenA@TargetsTest06:src/test/resources/targets-test06.dot")))))
+
+ private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
+}
+
+public val Target.Companion.app: AppTargets
+ get() = AppTargets()
+
+public val Target.Companion.main: MainTargets
+ get() = MainTargets()
diff --git a/way-gradle-plugin/src/test/resources/targets-test06.dot b/way-gradle-plugin/src/test/resources/targets-test06.dot
new file mode 100644
index 0000000..a179079
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test06.dot
@@ -0,0 +1,16 @@
+// history pseudostate targets (shallow + deep) alongside screens
+digraph TargetsTest06 {
+ schemaFileName = "targets-test06-schema"
+ targetsFileName = "targets-test06-targets"
+
+ app [type = flow]
+ main [type = flow]
+
+ mainHist [type = "history"]
+ mainDeepHist [type = "deepHistory"]
+
+ app -> main -> screenA
+ main -> screenB
+ main -> mainHist
+ main -> mainDeepHist
+}
diff --git a/way-gradle-plugin/src/test/resources/targets-test07-targets.txt b/way-gradle-plugin/src/test/resources/targets-test07-targets.txt
new file mode 100644
index 0000000..0e4a335
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test07-targets.txt
@@ -0,0 +1,23 @@
+package ru.kode.test.app.schema
+
+import ru.kode.way.FlowTarget
+import ru.kode.way.HistoryTarget
+import ru.kode.way.Path
+import ru.kode.way.Segment
+import ru.kode.way.Target
+import ru.kode.way.append
+
+public class AppTargets(
+ private val prefix: Path? = null,
+) {
+ public val tabs: FlowTarget =
+ FlowTarget(flowPath(Path(listOf(Segment("tabs@TargetsTest07:src/test/resources/targets-test07.dot")))))
+
+ public val tabsHist: HistoryTarget =
+ HistoryTarget(flowPath(Path(listOf(Segment("app@TargetsTest07:src/test/resources/targets-test07.dot"), Segment("tabs@TargetsTest07:src/test/resources/targets-test07.dot")))), deep = true)
+
+ private fun flowPath(path: Path): Path = prefix?.append(path) ?: path
+}
+
+public val Target.Companion.app: AppTargets
+ get() = AppTargets()
diff --git a/way-gradle-plugin/src/test/resources/targets-test07.dot b/way-gradle-plugin/src/test/resources/targets-test07.dot
new file mode 100644
index 0000000..5443a34
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/targets-test07.dot
@@ -0,0 +1,14 @@
+// history pseudostate declared under a PARALLEL parent; accessor hosts in the enclosing flow
+digraph TargetsTest07 {
+ schemaFileName = "targets-test07-schema"
+ targetsFileName = "targets-test07-targets"
+
+ app [type = flow]
+ tabs [type = parallelFlow]
+ tabsHist [type = "deepHistory"]
+
+ app -> tabs
+ tabs -> tabsHist
+ tabs -> tabA -> screenA
+ tabs -> tabB -> screenB
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-cycle.dot b/way-gradle-plugin/src/test/resources/validation-cycle.dot
new file mode 100644
index 0000000..7459281
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-cycle.dot
@@ -0,0 +1,7 @@
+digraph ValidationCycle {
+ app [type = flow]
+ screen1
+ screen2
+
+ app -> screen1 -> screen2 -> screen1
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-disconnected-cycle.dot b/way-gradle-plugin/src/test/resources/validation-disconnected-cycle.dot
new file mode 100644
index 0000000..5ad950d
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-disconnected-cycle.dot
@@ -0,0 +1,6 @@
+digraph {
+ app [type=flow]
+ app -> screen1
+ x -> y
+ y -> x
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-disconnected.dot b/way-gradle-plugin/src/test/resources/validation-disconnected.dot
new file mode 100644
index 0000000..b0483e7
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-disconnected.dot
@@ -0,0 +1,10 @@
+digraph ValidationDisconnected {
+ app [type = flow]
+ screen1
+
+ orphanA
+ orphanB
+
+ app -> screen1
+ orphanA -> orphanB
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-duplicate-node.dot b/way-gradle-plugin/src/test/resources/validation-duplicate-node.dot
new file mode 100644
index 0000000..0b4e651
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-duplicate-node.dot
@@ -0,0 +1,5 @@
+digraph {
+ app [type=flow]
+ app [type=schema]
+ app -> screen1
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-empty-flow.dot b/way-gradle-plugin/src/test/resources/validation-empty-flow.dot
new file mode 100644
index 0000000..d85ff08
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-empty-flow.dot
@@ -0,0 +1,3 @@
+digraph ValidationEmptyFlow {
+ app [type = flow]
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-empty-parallel.dot b/way-gradle-plugin/src/test/resources/validation-empty-parallel.dot
new file mode 100644
index 0000000..1d95aae
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-empty-parallel.dot
@@ -0,0 +1,7 @@
+digraph {
+ app [type = flow]
+ main [type = parallelFlow]
+ emptyChild [type = parallelFlow]
+ app -> main
+ main -> emptyChild
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-fan-in.dot b/way-gradle-plugin/src/test/resources/validation-fan-in.dot
new file mode 100644
index 0000000..c208c3b
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-fan-in.dot
@@ -0,0 +1,11 @@
+digraph ValidationFanIn {
+ app [type = flow]
+ screen1
+ screen2
+ shared
+
+ app -> screen1
+ app -> screen2
+ screen1 -> shared
+ screen2 -> shared
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-half-param.dot b/way-gradle-plugin/src/test/resources/validation-half-param.dot
new file mode 100644
index 0000000..3ac4ede
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-half-param.dot
@@ -0,0 +1,5 @@
+digraph {
+ app [type = flow]
+ screen1 [parameterName = "userId"]
+ app -> screen1
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-invalid-graph-id.dot b/way-gradle-plugin/src/test/resources/validation-invalid-graph-id.dot
new file mode 100644
index 0000000..b35eec0
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-invalid-graph-id.dot
@@ -0,0 +1,6 @@
+digraph "bad-graph" {
+ app [type = flow]
+ login
+
+ app -> login
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-invalid-node-id.dot b/way-gradle-plugin/src/test/resources/validation-invalid-node-id.dot
new file mode 100644
index 0000000..60a8a26
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-invalid-node-id.dot
@@ -0,0 +1,6 @@
+digraph ValidationInvalidNodeId {
+ app [type = flow]
+ "login-screen"
+
+ app -> "login-screen"
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-multi-root.dot b/way-gradle-plugin/src/test/resources/validation-multi-root.dot
new file mode 100644
index 0000000..d7ede7a
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-multi-root.dot
@@ -0,0 +1,8 @@
+digraph {
+ flowA [type = flow]
+ flowB [type = flow]
+ screenA [type = flow]
+ screenB [type = flow]
+ flowA -> screenA
+ flowB -> screenB
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-quoted-nodes.dot b/way-gradle-plugin/src/test/resources/validation-quoted-nodes.dot
new file mode 100644
index 0000000..d1e6457
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-quoted-nodes.dot
@@ -0,0 +1,6 @@
+digraph ValidationQuotedNodes {
+ "myFlow" [type = flow]
+ "myScreen"
+
+ "myFlow" -> "myScreen"
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-self-loop.dot b/way-gradle-plugin/src/test/resources/validation-self-loop.dot
new file mode 100644
index 0000000..7524595
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-self-loop.dot
@@ -0,0 +1,5 @@
+digraph {
+ app [type=flow]
+ app -> screen1
+ screen1 -> screen1
+}
diff --git a/way-gradle-plugin/src/test/resources/validation-sibling-screen-schema.dot b/way-gradle-plugin/src/test/resources/validation-sibling-screen-schema.dot
new file mode 100644
index 0000000..f34a6d7
--- /dev/null
+++ b/way-gradle-plugin/src/test/resources/validation-sibling-screen-schema.dot
@@ -0,0 +1,10 @@
+// Reproduces the "sibling screen + schema" pattern:
+// mainFlow has both a screen node (main) and a schema node (chatFlow) as direct children.
+// Navigating to chatFlow will dismiss main from the alive stack.
+digraph Main {
+ package = "ru.kode.way.test"
+ mainFlow [type = "flow"]
+ chatFlow [type = "schema"]
+ mainFlow -> main
+ mainFlow -> chatFlow
+}
diff --git a/way/src/commonMain/kotlin/ru/kode/way/BackRouting.kt b/way/src/commonMain/kotlin/ru/kode/way/BackRouting.kt
new file mode 100644
index 0000000..9d95a1a
--- /dev/null
+++ b/way/src/commonMain/kotlin/ru/kode/way/BackRouting.kt
@@ -0,0 +1,50 @@
+package ru.kode.way
+
+/**
+ * Resolves [regionId] to one of the [candidates] (the alive sub-regions of a parallel node).
+ * Three tiers, narrowest first:
+ *
+ * 1. **Strict equality** — `regionId in candidates`. The common case when the caller already
+ * supplies an absolute id pulled out of `NavigationState.regions.keys`.
+ * 2. **Strict full-id suffix** — every segment of `regionId.path` equals the trailing segments of
+ * the candidate by full [Segment.id]. Covers bare-segment test fixtures and any in-module
+ * schema-local constant where the boundary `@graphId:file` disambiguator happens to agree.
+ * 3. **Boundary-tolerant suffix** — only the first segment of `regionId.path` (the schema-root
+ * mount point) is matched by [Segment.name]; every deeper segment still requires full id
+ * equality. Covers cross-module schema imports where the parent module's codegen stamps its own
+ * `@graphId:file` on the boundary segment while the leaf module's codegen stamps the leaf's. The
+ * relaxation is safe because boundary segment names are unique within one parallel parent's
+ * `childSchemas` and deeper segments still have to match strictly.
+ *
+ * Returns `null` if no tier matches — Back soft-falls-back to [deepestRegion]; NavigateTo-from-a-
+ * parallel treats it as an error.
+ */
+internal fun resolveRegionId(regionId: RegionId, candidates: Collection): RegionId? {
+ if (regionId in candidates) return regionId
+ candidates.firstOrNull { it.path.endsWith(regionId.path) }?.let { return it }
+ return candidates.firstOrNull { it.path.endsWithSchemaLocal(regionId.path) }
+}
+
+/**
+ * Selects the sub-region that Back should target when a parallel node declines to name one (returns
+ * [Ignore] or a stale/unresolved [DispatchBackTo]): the region with the longest active path.
+ *
+ * When several regions share that maximal depth, the tiebreaker is the [RegionId.path] string — a
+ * stable, KMP-safe alphabetical fallback. Never throws: a parallel always has at least one active
+ * sub-region when Back reaches it.
+ */
+internal fun deepestRegion(subRegionActivePaths: Map): RegionId {
+ val maxLen = subRegionActivePaths.values.maxOf { it.length }
+ // maxByOrNull already returns the sole element for a single deepest region, so no special-case needed.
+ return subRegionActivePaths
+ .filterValues { it.length == maxLen }.keys
+ .maxByOrNull { it.path.toString() }!!
+}
+
+/**
+ * Picks the sub-region a Back should enter: the [requested] id normalized against the alive
+ * [subRegions] via [resolveRegionId], soft-falling-back to the [deepestRegion] when [requested] is
+ * null or stale/unresolved. Back never throws.
+ */
+internal fun chooseBackRegion(requested: RegionId?, subRegions: Map): RegionId =
+ resolveRegionId(requested ?: return deepestRegion(subRegions), subRegions.keys) ?: deepestRegion(subRegions)
diff --git a/way/src/commonMain/kotlin/ru/kode/way/CrossRegionEvent.kt b/way/src/commonMain/kotlin/ru/kode/way/CrossRegionEvent.kt
new file mode 100644
index 0000000..4b9c2e0
--- /dev/null
+++ b/way/src/commonMain/kotlin/ru/kode/way/CrossRegionEvent.kt
@@ -0,0 +1,26 @@
+package ru.kode.way
+
+/**
+ * Marks an [Event] type that is intended to bubble through a child [FlowNode] and be handled by
+ * a parent [ParallelFlowNode] (typically to switch focused regions or coordinate cross-tab navigation).
+ *
+ * Child FlowNodes that receive a `@CrossRegionEvent`-annotated event MUST return [Ignore] so it
+ * reaches the parallel parent. A child that handles the event with any other transition swallows
+ * it, silently breaking the intended cross-region routing.
+ *
+ * This release ships the annotation as documentation only — it surfaces intent in the source and
+ * gives reviewers a hook to grep for. A subsequent release will add a codegen-time check that
+ * warns when a child flow's generated transition matches such an event with a non-`Ignore` return
+ * (the warning becomes an error once consumers have had a release to migrate).
+ *
+ * Example:
+ * ```kotlin
+ * sealed interface HomeFlowEvent : Event {
+ * @CrossRegionEvent
+ * data object ShowProfileRequested : HomeFlowEvent
+ * }
+ * ```
+ */
+@kotlin.annotation.Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.BINARY)
+annotation class CrossRegionEvent
diff --git a/way/src/commonMain/kotlin/ru/kode/way/FlowTransition.kt b/way/src/commonMain/kotlin/ru/kode/way/FlowTransition.kt
index 8f1007a..2eed49e 100644
--- a/way/src/commonMain/kotlin/ru/kode/way/FlowTransition.kt
+++ b/way/src/commonMain/kotlin/ru/kode/way/FlowTransition.kt
@@ -5,26 +5,142 @@ sealed interface Transition
sealed interface FlowTransition : Transition
sealed interface ScreenTransition : Transition
-data class NavigateTo(val targets: Set) :
+/**
+ * Navigates to one or more [Target] nodes.
+ *
+ * When returned from a [ScreenNode.transition], the targets must be siblings within the same
+ * parent flow schema — i.e. nodes declared at the same level in the flow graph as the current
+ * screen. Attempting to navigate to a node outside the current schema scope will result in a
+ * resolution error at runtime.
+ *
+ * When returned from a [FlowNode.transition] or [ParallelFlowNode.transition], targets may refer to
+ * any node reachable from the current schema, including sibling flows and parallel regions.
+ *
+ * ### Multiple targets
+ *
+ * The primary use of multiple targets is to set the active path in several parallel regions at
+ * once (e.g. deep-linking into two sub-regions of a parallel flow simultaneously). [targets] is a
+ * [List] — order is significant — with the following contract:
+ * - Targets are applied **in list order**.
+ * - Two targets that resolve to the **same region** collapse to the **last one** (its path and
+ * payload win). A duplicate/identical target is therefore idempotent.
+ * - Targets that resolve to **different regions** are independent — the resulting state does not
+ * depend on their relative order.
+ * - When multiple targets initialize the **same cold parallel**, the first target to reach it
+ * materializes its sub-regions and later targets refine specific ones without resetting the
+ * siblings the earlier target already placed.
+ *
+ * A [List] (not a [Set]) is used precisely so this ordering is explicit and a caller cannot pass
+ * an unordered collection that would make same-region resolution non-deterministic.
+ */
+data class NavigateTo(val targets: List) :
FlowTransition,
ScreenTransition {
+ init {
+ require(targets.isNotEmpty()) {
+ "NavigateTo requires at least one target. Use Stay to keep the current navigation, " +
+ "or Ignore to defer to the parent."
+ }
+ }
+
constructor(
target: Target,
- ) : this(setOf(target))
+ ) : this(listOf(target))
}
+/**
+ * Finishes the current [FlowNode] with [result].
+ *
+ * The runtime delivers a finish-request event to the parent [FlowNode], which must handle it
+ * with a matching event type. The [result] type [R] must match the result type declared in the
+ * schema's child-finish event generated for this flow.
+ *
+ * Returning [Finish] from a root flow (one whose parent is the [NavigationService] itself)
+ * invokes the [NavigationService]'s `onFinishRequest` callback.
+ */
data class Finish(val result: R) : FlowTransition
+
+/**
+ * Schedules [event] to be processed after the current transition (and all its side-effects)
+ * have fully completed.
+ *
+ * **Ordering guarantee:** enqueued events are appended to a FIFO queue. If a transition produces
+ * multiple [EnqueueEvent] results (across parallel regions), they are appended in region-iteration
+ * order and each is dispatched one at a time. Each dispatched event may itself produce further
+ * [EnqueueEvent] results, which are appended to the tail of the same queue.
+ */
data class EnqueueEvent(val event: Event) :
FlowTransition,
ScreenTransition
/**
- * Consumes event and stays on the current node
+ * Composes a [NavigateTo] with one or more follow-up events that the runtime enqueues after the
+ * navigation completes. Use the [thenEnqueue] infix function to construct it fluently:
+ *
+ * ```kotlin
+ * return NavigateTo(Target.appFlow.homeFlow) thenEnqueue Sim.Details(simId)
+ * ```
+ *
+ * The follow-up events join the same FIFO queue [EnqueueEvent] uses — they are dispatched after
+ * the current transition's side-effects (including the navigation's onEntry hooks) complete.
+ *
+ * This removes the need for ad-hoc "post-entry action" plumbing — a previously common pattern was
+ * to assisted-inject a lambda into the destination flow's root node and invoke it from `onEntry`.
+ * With [NavigateAndEnqueue], the caller declares the follow-up at the navigation site, and the
+ * destination flow handles the event through its normal `transition` function.
+ */
+data class NavigateAndEnqueue(val navigate: NavigateTo, val events: List) :
+ FlowTransition,
+ ScreenTransition {
+ init {
+ require(events.isNotEmpty()) {
+ "NavigateAndEnqueue must carry at least one follow-up event; use plain NavigateTo otherwise."
+ }
+ }
+}
+
+infix fun NavigateTo.thenEnqueue(event: Event): NavigateAndEnqueue = NavigateAndEnqueue(this, listOf(event))
+
+infix fun NavigateAndEnqueue.thenEnqueue(event: Event): NavigateAndEnqueue = copy(events = events + event)
+
+/**
+ * Routes a structural Back into the sub-region named by [regionId] when returned from a
+ * [ParallelFlowNode.transition] handling [Event.Back].
+ *
+ * A parallel node coexists in several sub-regions at once, so a Back press has no inherent target.
+ * Return `DispatchBackTo(regionId)` from `transition(Event.Back)` to declare which sub-region should
+ * receive the Back — typically the one the app is currently presenting. The runtime then performs
+ * the ordinary structural back-pop (screen pop / flow finish / nested-parallel recursion) inside
+ * that region, exactly as if the Back had originated there.
+ *
+ * [regionId] may be either an absolute key from `NavigationState.regions` or a schema-local id
+ * (such as `MyParallelFlowSchema.exploreFlowRegionId`); the runtime suffix-matches schema-local ids
+ * against the parallel's active sub-regions. A stale or unresolved id never crashes Back — it
+ * soft-falls-back to the deepest active sub-region.
+ *
+ * This is a parallel-only transition (declared `FlowTransition`). Returning it from a plain
+ * [FlowNode] or [ScreenNode] is a misuse and is rejected at resolution time.
+ */
+data class DispatchBackTo(val regionId: RegionId) : FlowTransition
+
+/**
+ * Consumes the event and stays on the current node without changing navigation state.
+ *
+ * The navigation state is re-emitted to all listeners with the same active path, allowing
+ * observers that subscribe to state updates to react even though no navigation occurred.
*/
object Stay : FlowTransition, ScreenTransition
/**
- * Ignores event and lets any parent node with a defined handler to process it
+ * Ignores the event and bubbles it to the parent node for handling.
+ *
+ * If a [ScreenNode] returns [Ignore], the event is passed to the parent [FlowNode] or
+ * [ParallelFlowNode]. If the root flow node also returns [Ignore] and there are no more
+ * ancestors, the event is silently dropped — with one exception: for [Event.Back] received at a
+ * region root with [Ignore], the runtime applies default back semantics (finishing the parent
+ * flow with `dismissResult`, popping to the previous screen, or, at a parallel node, routing Back
+ * into the deepest active sub-region) rather than dropping the event. A parallel node can override
+ * that default by returning [DispatchBackTo] from its `transition(Event.Back)`.
*/
object Ignore : FlowTransition, ScreenTransition
diff --git a/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt b/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt
index 10eed8e..aa65d91 100644
--- a/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt
+++ b/way/src/commonMain/kotlin/ru/kode/way/GraphTransitions.kt
@@ -1,12 +1,76 @@
package ru.kode.way
-internal fun calculateAliveNodes(state: NavigationState, targets: Map): NavigationState {
+/**
+ * Updates [state] in-place by recomputing alive/active paths for each region based on [targets].
+ * IMPORTANT: This function mutates [state._regions] directly. Callers that need the pre-mutation
+ * state must snapshot it before calling this function (see [NavigationService.transition]).
+ * Returns the same [state] object for convenience.
+ */
+internal fun calculateAliveNodes(
+ state: NavigationState,
+ targets: Map,
+ schema: Schema,
+): NavigationState {
targets.entries.forEach { (regionId, path) ->
- val region = state._regions[regionId] ?: error("no region with id=\"$regionId\"")
- val steps = path.toSteps()
- region._alive.removeAll { !steps.contains(it) }
- steps.forEach { if (!region._alive.contains(it)) region._alive.add(it) }
+ val steps = path.toSteps().filter { it.startsWith(regionId.path) }.toList()
+ // A target path must pass through its region's root; otherwise `steps` is empty, `_alive`
+ // would be emptied below and `_active = _alive.last()` would throw an opaque
+ // NoSuchElementException. Fail with an actionable message instead — this signals the target
+ // was routed to a region whose root is not a prefix of the target (an upstream resolution bug).
+ check(steps.isNotEmpty()) {
+ "calculateAliveNodes: target path \"$path\" does not pass through region root \"${regionId.path}\"; " +
+ "cannot compute alive nodes. The target was routed to the wrong region."
+ }
+ val region = state._regions.getOrPut(regionId) {
+ Region(
+ _nodes = mutableMapOf(),
+ _active = path,
+ _alive = mutableListOf(),
+ _rootFinishTransitionBuilder = computeSubRegionFinishBuilder(schema, regionId),
+ )
+ }
+
+ val stepsSet = steps.toHashSet()
+ val aliveSet = region._alive.toHashSet()
+ region._alive.removeAll { it !in stepsSet }
+ steps.forEach { if (it !in aliveSet) region._alive.add(it) }
region._active = region._alive.last()
}
+
+ pruneOrphanRegions(state, schema)
return state
}
+
+/**
+ * The SCXML "configuration": the set of every currently-active absolute [Path] across all regions —
+ * the union of each region's `alive` list. This is the explicit state set the canonical statechart
+ * functions in StatechartAlgorithm.kt (e.g. [computeExitSet]) operate over. Centralizing it here
+ * gives the runtime one named abstraction instead of ad-hoc `flatMap { it.alive }` reconstructions.
+ */
+internal fun computeConfiguration(state: NavigationState): Set =
+ state._regions.values.flatMapTo(mutableSetOf()) { it.alive }
+
+/**
+ * Removes from [state]._regions every sub-region whose parent parallel is no longer reachable, keeping:
+ * - schema-declared top-level regions (always retained), and
+ * - sub-regions whose parent parallel is still alive — either (a) a runtime node alive in a sibling
+ * region (sub-regions lazily mounted by NavigateTo) or (b) an intermediate parallel root
+ * (parallel-rooted schema mounted as a sub-region of another parallel-rooted schema) that lives in
+ * [NavigationState._intermediateParallels] rather than any region. Without the (b) check,
+ * intermediate-parallel sub-regions would be dropped on the first pass because their parent isn't in
+ * any `alive` list.
+ *
+ * Shared by [calculateAliveNodes] and the post-update intermediate-unmount sweep in
+ * [NavigationService.transition] so the two callers cannot drift.
+ */
+internal fun pruneOrphanRegions(state: NavigationState, schema: Schema) {
+ state._regions.keys.retainAll { regionId ->
+ if (schema.regions.contains(regionId)) return@retainAll true
+ val parallelParentPath = regionId.path.dropLast(1)
+ if (parallelParentPath in state._intermediateParallels.keys) return@retainAll true
+ val parentAliveInSibling = state._regions.entries.any { (otherId, otherRegion) ->
+ otherId != regionId && parallelParentPath in otherRegion.alive
+ }
+ parentAliveInSibling
+ }
+}
diff --git a/way/src/commonMain/kotlin/ru/kode/way/NavigationService.kt b/way/src/commonMain/kotlin/ru/kode/way/NavigationService.kt
index 698b90e..d6af119 100644
--- a/way/src/commonMain/kotlin/ru/kode/way/NavigationService.kt
+++ b/way/src/commonMain/kotlin/ru/kode/way/NavigationService.kt
@@ -1,5 +1,13 @@
package ru.kode.way
+/**
+ * Drives navigation state for a single root flow.
+ *
+ * **Threading:** NavigationService is NOT thread-safe. All calls to [sendEvent], [start],
+ * [addTransitionListener], etc. must be made from the same thread (typically the main/UI thread).
+ * The reentrancy guard ([isDispatching]) only protects against single-threaded re-entry from
+ * within listener callbacks, not concurrent access from multiple threads.
+ */
class NavigationService(
private val nodeBuilder: NodeBuilder,
private val onFinishRequest: (R) -> FlowTransition,
@@ -11,77 +19,529 @@ class NavigationService(
)
private val listeners = ArrayList<(NavigationState) -> Unit>()
private val serviceExtensionPoints = mutableListOf>()
- private var enqueuedEventScheduler: (Event) -> Unit = { sendEvent(it) }
+ private var enqueuedEventScheduler: ((Event) -> Unit)? = null
+ private var isDispatching = false
+ private var isDisposed = false
+
+ /**
+ * [onFinishRequest] with its types erased to `(Any) -> Transition`. A root/intermediate parallel-flow
+ * (whose `R` is bound to the service's `R`) routes its `Finish` result through this builder to reach
+ * [onFinishRequest]. Computed once here rather than casting inline at each use site.
+ */
+ @Suppress("UNCHECKED_CAST")
+ private val erasedFinishRequest: (Any) -> Transition = onFinishRequest as (Any) -> Transition
+
+ /**
+ * The finish-transition builder for a node rooted at [path]. A schema-root path (single segment)
+ * means the node sits AT the service root, so its `Finish` bubbles to [onFinishRequest] via
+ * [erasedFinishRequest]; otherwise `Finish` routes through [computeSubRegionFinishBuilder] so the
+ * enclosing parallel-flow sees a typed `ChildFinishRequest`.
+ */
+ private fun finishBuilderFor(path: Path): (Any) -> Transition =
+ if (path.isSchemaRoot) erasedFinishRequest else computeSubRegionFinishBuilder(nodeBuilder.schema, RegionId(path))
+
+ /**
+ * When true, validates that nodes returned by [NodeBuilder] match the types declared in the schema on every
+ * transition. Enabled by default; can be disabled in production builds for performance.
+ */
+ var validateSchema: Boolean = true
fun start(rootFlowPayload: Any? = null) {
+ check(!isStarted()) { "NavigationService is already started; start() must only be called once" }
sendEvent(InitEvent(rootFlowPayload))
}
fun isStarted(): Boolean = state.isInitialized()
+ /**
+ * Registers a listener that receives the [NavigationState] after every transition.
+ *
+ * If [start] has already been called, the listener is invoked immediately with the current state
+ * as part of registration. If that immediate-invocation throws, the listener is automatically
+ * removed before the exception propagates to the caller.
+ *
+ * During normal dispatch (inside [sendEvent]), listeners are notified independently — one
+ * listener throwing does NOT skip subsequent listeners. The first thrown exception propagates
+ * out of [sendEvent] after every listener has been called; further exceptions thrown by later
+ * listeners are suppressed and attached as `Throwable.suppressed` to the first one.
+ */
fun addTransitionListener(listener: (NavigationState) -> Unit) {
+ if (isDisposed) return
listeners.add(listener)
if (state.isInitialized()) {
- listener(state)
+ try {
+ listener(state.copy())
+ } catch (e: Throwable) {
+ listeners.remove(listener)
+ throw e
+ }
}
}
fun removeTransitionListener(listener: (NavigationState) -> Unit) {
+ if (isDisposed) return
listeners.remove(listener)
}
fun addNodeExtensionPoint(point: NodeExtensionPoint) {
+ if (isDisposed) return
state._nodeExtensionPoints.add(point)
}
fun removeNodeExtensionPoint(point: NodeExtensionPoint) {
+ if (isDisposed) return
state._nodeExtensionPoints.remove(point)
}
fun addServiceExtensionPoint(point: ServiceExtensionPoint) {
+ if (isDisposed) return
serviceExtensionPoints.add(point)
}
fun removeServiceExtensionPoint(point: ServiceExtensionPoint) {
+ if (isDisposed) return
serviceExtensionPoints.remove(point)
}
+ /**
+ * Releases all listeners and extension points held by this service.
+ *
+ * After calling [dispose], calls to [sendEvent] become safe no-ops: they will return immediately
+ * without processing the event or delivering state to any listener. Calling [start] after [dispose]
+ * is undefined behaviour and should be avoided.
+ *
+ * **Note:** [dispose] does NOT call [Node.onExit] or [Node.onDispose] on currently-alive nodes.
+ * Use [cleanDispose] if you need [Node.onDispose] to fire on all alive nodes before shutdown.
+ *
+ * This method is idempotent: calling it more than once has no additional effect.
+ */
+ fun dispose() {
+ if (isDisposed) return
+ check(!isDispatching) {
+ "dispose() must not be called during event dispatch (e.g. from inside a transition " +
+ "listener or extension point). Call it after sendEvent() returns. Use cleanDispose() " +
+ "for the same constraint with leaf-to-root onDispose firing."
+ }
+ isDisposed = true
+ listeners.clear()
+ serviceExtensionPoints.clear()
+ state._nodeExtensionPoints.clear()
+ state._enqueuedEvents.clear()
+ state._regions.clear()
+ state._intermediateParallels.clear()
+ }
+
+ /**
+ * Calls [Node.onDispose] on every currently-alive node in leaf-to-root order, then performs
+ * the same hard stop as [dispose].
+ *
+ * Ordering guarantees:
+ * - Sub-regions are disposed before the [ParallelFlowNode] that owns them.
+ * - Within each region nodes are disposed leaf-first (deepest active node before its ancestors).
+ * - The relative order between sibling sub-regions at the same depth is unspecified.
+ *
+ * Each step — extension-point `onPreDispose`, [Node.onDispose], and extension-point
+ * `onPostDispose` — is individually wrapped in `runCatching`, so a throwing hook or node
+ * does not skip the remaining steps or the remaining nodes.
+ *
+ * Must not be called from inside a transition listener or extension-point callback (i.e. during
+ * event dispatch). Call it only after [sendEvent] returns, typically from [android.arch.lifecycle.ViewModel.onCleared].
+ *
+ * This method is idempotent: calling it more than once has no additional effect.
+ */
+ fun cleanDispose() {
+ if (isDisposed) return
+ check(!isDispatching) {
+ "cleanDispose() must not be called during event dispatch (e.g. from inside a transition " +
+ "listener or extension point). Call it after sendEvent() returns."
+ }
+ if (state.isInitialized()) {
+ state._regions.entries
+ .sortedByDescending { it.key.path.length }
+ .forEach { (_, region) ->
+ region.alive.reversed().forEach { path ->
+ val node = region.nodes[path] ?: return@forEach
+ callOnDispose(node, path, state._nodeExtensionPoints)
+ }
+ }
+ // Dispose intermediate parallel roots (parallel-rooted sub-region roots wrapping another
+ // parallel-rooted schema) AFTER their inner regions — deepest-first so the outermost
+ // intermediate parallel sees its own children disposed before it is.
+ state._intermediateParallels.entries
+ .sortedByDescending { it.key.length }
+ .forEach { (path, intermediate) ->
+ callOnDispose(intermediate.node, path, state._nodeExtensionPoints)
+ }
+ // For a parallel-flow-ROOTED schema the root ParallelFlowNode lives in `state.rootNode`
+ // (set at InitEvent), NOT in any region's `_nodes` map nor in `_intermediateParallels`
+ // (mountIntermediateParallel skips `parallelPath == rootNodePath`). Dispose it LAST — after
+ // every sub-region and intermediate — so it observes its children disposed first, matching
+ // the leaf-to-root guarantee. Without this its onDispose() never fires and any scope/DI it
+ // holds leaks on cleanDispose().
+ val rootNode = state.rootNode
+ val rootNodePath = state.rootNodePath
+ if (rootNode != null && rootNodePath != null) {
+ callOnDispose(rootNode, rootNodePath, state._nodeExtensionPoints)
+ }
+ }
+ dispose()
+ }
+
+ /**
+ * All-or-nothing snapshot of the mutable navigation state taken before a transition runs, so a
+ * throw anywhere in [transition] can [restore] the exact pre-transition state. The `_regions`
+ * entries are deep-copied at capture; every other collection is a shallow copy of immutable
+ * references. For an [InitEvent] all captured collections are empty, so [restore] cleanly resets
+ * a failed `start()` to the pre-init state.
+ */
+ private class TransactionSnapshot(state: NavigationState) {
+ private val regions: Map = state._regions.mapValues { it.value.copy() }
+ private val enqueuedEvents: List = state._enqueuedEvents.toList()
+ private val payloads: Map = state._payloads.toMap()
+ private val intermediateParallels: Map = state._intermediateParallels.toMap()
+ private val history: Map> = state._history.toMap()
+ private val rootNode: Node? = state.rootNode
+ private val rootNodePath: Path? = state.rootNodePath
+ private val rootFinishTransitionBuilder: ((Any) -> Transition)? = state._rootFinishTransitionBuilder
+
+ fun restore(state: NavigationState) {
+ state._regions.clear()
+ state._regions.putAll(regions)
+ state._enqueuedEvents.clear()
+ state._enqueuedEvents.addAll(enqueuedEvents)
+ state._payloads.clear()
+ state._payloads.putAll(payloads)
+ state._intermediateParallels.clear()
+ state._intermediateParallels.putAll(intermediateParallels)
+ state._history.clear()
+ state._history.putAll(history)
+ state.rootNode = rootNode
+ state.rootNodePath = rootNodePath
+ state._rootFinishTransitionBuilder = rootFinishTransitionBuilder
+ }
+ }
+
private fun transition(state: NavigationState, event: Event): NavigationState {
check(event is InitEvent || state.isInitialized()) {
- "internal error: no regions in state after Event.Init"
+ "sendEvent() was called before start(); call NavigationService.start() first"
}
- serviceExtensionPoints.forEach {
+ serviceExtensionPoints.toList().forEach {
it.onPreTransition(this, event, state.copy())
}
+ // Snapshot every mutable slot before any mutation so a throw anywhere below restores the exact
+ // pre-transition state (all-or-nothing). Taken before InitEvent populates regions, so a failed
+ // start() restores to empty and is retryable.
+ val snapshot = TransactionSnapshot(state)
+ // Root flow nodes entered by the InitEvent block, tracked so the catch can compensate their
+ // onEntry. applyResolvedTransition tracks its own entered/exited separately.
+ val initEnteredRoots = mutableListOf>()
+ val navigationState = try {
+ applyResolvedTransition(state, event, initEnteredRoots)
+ } catch (e: Throwable) {
+ // Compensate onEntry for root flow nodes entered in the InitEvent block (applyResolvedTransition
+ // compensates its own nodes); then restore the snapshot so the whole dispatch is all-or-nothing.
+ compensateLifecycle(initEnteredRoots, exited = emptyList(), event, state._nodeExtensionPoints)
+ snapshot.restore(state)
+ throw e
+ }
+ // Called after the transition is fully committed. Exceptions here propagate to the caller
+ // but do not roll back navigation state — the transition has already completed.
+ serviceExtensionPoints.toList().forEach { it.onPostTransition(this, event, navigationState.copy()) }
+ return navigationState
+ }
+
+ /**
+ * Runs the committed body of a [transition]: materializes regions on InitEvent, resolves the event
+ * to target paths, recomputes the alive set, then synchronizes node lifecycles. Mutates [state] in
+ * place and returns the same instance. Appends InitEvent-entered roots to [initEnteredRoots] so the
+ * caller's catch can compensate them; its own inner lifecycle calls are compensated here on a throw.
+ * The caller ([transition]) owns the snapshot/restore for all-or-nothing rollback.
+ */
+ private fun applyResolvedTransition(
+ state: NavigationState,
+ event: Event,
+ initEnteredRoots: MutableList>,
+ ): NavigationState {
if (event is InitEvent) {
+ enterRootParallelIfNeeded(state, event, initEnteredRoots)
nodeBuilder.schema.regions.forEach { regionId ->
- val regionRootPath = regionId.path
- val regionRoot = nodeBuilder.build(
- regionRootPath,
- payloads = event.payload?.let { mapOf(regionRootPath to it) } ?: emptyMap(),
- rootSegmentAlias = null,
- )
- require(regionRoot is FlowNode<*>) {
- "expected FlowNode at $regionId, but builder returned ${regionRoot::class.simpleName}"
- }
+ materializeRegion(regionId, event, initEnteredRoots)
+ }
+ }
+ val resolvedTransition = resolveTransition(
+ regions = state.regions,
+ nodeBuilder = nodeBuilder,
+ event = event,
+ extensionPoints = state._nodeExtensionPoints,
+ rootNode = state.rootNode,
+ rootNodePath = state.rootNodePath,
+ rootFinishTransitionBuilder = state._rootFinishTransitionBuilder,
+ intermediateParallels = state._intermediateParallels,
+ history = state._history,
+ )
+ // Persist this transition's payloads into the running store BEFORE synchronizeNodes —
+ // any lazily-rebuilt NodeBuilder with a parameterised flow lookup hits the store, not the
+ // transient transition map. This is what fixes "no payload for " on subsequent
+ // re-builds (e.g. after `invalidateCache` removes a previously-cached child NodeBuilder
+ // and a later event causes it to be rebuilt without its own NavigateTo).
+ state._payloads.putAll(resolvedTransition.payloads)
+ val previousAlive = state._regions.mapValues { it.value.alive.toList() }
+ val previousNodes = state._regions.mapValues { it.value.nodes.toMap() }
+ // Hoisted out of synchronizeNodes so the runtime pre-mount and pre-unmount of intermediate
+ // parallels can append to the same compensation lists — the inner catch below replays
+ // every phase's enter/exit in lockstep regardless of which one threw.
+ val syncEntered = mutableListOf>()
+ val syncExited = mutableListOf>()
+ premountIntermediates(state, event, resolvedTransition, syncEntered)
+ val unmountedIntermediates = mutableSetOf