Skip to content

Commit ab226d1

Browse files
authored
Merge pull request #92 from nsk90/mermaid-export
Add Mermaid export feature
2 parents 83bcb79 + 7019eec commit ab226d1

File tree

10 files changed

+425
-65
lines changed

10 files changed

+425
-65
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,12 @@ fun main() = runBlocking {
128128
<img src="https://github.com/nsk90/android-kstatemachine-sample/blob/main/images/android-app-sample.gif"
129129
alt="Android sample app" width="30%" height="30%"/>
130130
</p>
131-
131+
* [Finished state sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedStateSample.kt)
132132
* [Transition on FinishedEvent sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventSample.kt)
133133
* [FinishedEvent using with DataState sample](./samples/src/commonMain/kotlin/ru/nsk/samples/FinishedEventDataStateSample.kt)
134134
* [Undo transition sample](./samples/src/commonMain/kotlin/ru/nsk/samples/UndoTransitionSample.kt)
135135
* [PlantUML nested states export sample](./samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportSample.kt)
136+
* [Mermaid nested states export sample](./samples/src/commonMain/kotlin/ru/nsk/samples/MermaidExportSample.kt)
136137
* [Inherit transitions by grouping states sample](./samples/src/commonMain/kotlin/ru/nsk/samples/InheritTransitionsSample.kt)
137138
* [Minimal sealed classes sample](./samples/src/commonMain/kotlin/ru/nsk/samples/MinimalSealedClassesSample.kt)
138139
* [Usage without Kotlin Coroutines sample](./samples/src/commonMain/kotlin/ru/nsk/samples/StdLibMinimalSealedClassesSample.kt)

docs/index.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* [Migration guide from versions older than v0.20.0](#migration-guide-from-versions-older-than-v0200)
4646
* [Export](#export)
4747
* [PlantUML](#plantuml)
48+
* [Mermaid](#mermaid)
4849
* [Testing](#testing)
4950
* [Multiplatform](#multiplatform)
5051
* [Consider using Kotlin sealed classes](#consider-using-kotlin-sealed-classes)
@@ -433,7 +434,7 @@ createStateMachine(scope) {
433434
Some of state machines and states are infinite, but other ones may finish.
434435

435436
* In `ChildMode.EXCLUSIVE` state or state machine finishes when enters top-level final state.
436-
* In `ChildMode.PARALLEL` state or state machine finishes when all its children has finished.
437+
* In `ChildMode.PARALLEL` state or state machine finishes when all its direct children has finished.
437438

438439
To make a state final, it must implement `FinalState` marker interface.
439440
Built-in implementation of such state is `DefaultFinalState`.
@@ -447,6 +448,8 @@ sealed class States : DefaultState() {
447448
}
448449
```
449450

451+
See [Finished state sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/FinishedStateSample.kt)
452+
450453
Finishing of states and state machines is treated little differently.
451454
State machine that was finished stops processing incoming events.
452455
But when some nested state is finished its transitions are still active,
@@ -596,12 +599,12 @@ choiceState {
596599
}
597600
```
598601

599-
There is also `choiceDataState()` function available for choosing between `DataState`s. You can define `dataTransition`
602+
There is also `choiceDataState()` function available for choosing between `DataState`s. You can define `dataTransition`
600603
to target such pseudo data state.
601604

602605
You can use `choiceState` even on initial state branch.
603-
Note that `choiceState` can not be active, so if the library performs a transition and finds that `choiceState` is
604-
going to be activated, it executes its lambda argument and navigates to the resulting state.
606+
Note that `choiceState` can not be active, so if the library performs a transition and finds that `choiceState` is
607+
going to be activated, it executes its lambda argument and navigates to the resulting state.
605608
If the resulting state is also a `PseudoState` instance, further redirections might be applied.
606609

607610
### History state
@@ -652,7 +655,7 @@ createStateMachine(scope) {
652655
is activated it requires data value from a `DataEvent`. You can use `lastData` field to access last data value even
653656
after state exit, it falls back to `defaultData` if provided or throws.
654657

655-
### Target-less data transitions
658+
### Target-less data transitions
656659

657660
You can define target-less transitions for `DataState`. Please, note that if you want such transition to change state's
658661
`data` field, it should be `EXTERNAL` type. If target-less transition is `LOCAL` it does not change states data.
@@ -871,9 +874,14 @@ Contains additional functions to work with KStateMachine depending on Kotlin Cor
871874
## Export
872875
873876
> [!NOTE]
874-
> Currently transitions that use lambdas like `transitionConditionally()` and`transitionOn()` are not exported.
875-
> User defined lambdas that are passed to calculate next state could not be correctly
876-
> called during export process as they may touch application data that is not valid when export is running.
877+
> Transitions that use lambdas like `transitionConditionally()` and`transitionOn()` are not exported by default.
878+
> You can enable their export with `unsafeCallConditionalLambdas` flag of `exportToPlantUml()` function.
879+
> With `unsafeCallConditionalLambdas` flag set, user defined lambdas that are passed to the library to calculate next
880+
> state would be called during export process. This will give more complete (still not full) export output,
881+
> but may cause runtime errors depending on what the lambda actually do. As it may touch application data that is not
882+
> valid when export is running, also `event` argument will be faked by unsafe cast, so touching it
883+
> will cause `ClassCastException`
884+
> That is why `unsafeCallConditionalLambdas` flag should be considered as debug/development tool only.
877885
878886
### PlantUML
879887
@@ -889,6 +897,25 @@ Copy/paste resulting output to [Plant UML online editor](http://www.plantuml.com
889897
890898
See [PlantUML nested states export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportSample.kt)
891899
900+
### Mermaid
901+
902+
`Mermaid` uses almost the same text format as `PlantUML`.
903+
904+
Use `exportToMermaid()`/`exportToToMermaidBlocking()` extension function to export state machine
905+
to [Mermaid state diagram](https://mermaid.js.org/syntax/stateDiagram.html).
906+
907+
```kotlin
908+
val machine = createStateMachine(scope) { /* ... */ }
909+
println(machine.exportToPlantUml())
910+
```
911+
912+
`Intellij IDEA` users may use official [Mermaid plugin](https://plugins.jetbrains.com/plugin/20146-mermaid)
913+
to view diagrams directly in IDE for file types: `.mmd` and `.mermaid`.
914+
915+
Copy/paste resulting output to [Mermaid live editor](https://mermaid.live/)
916+
917+
See [Mermaid nested states export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/MermaidExportSample.kt)
918+
892919
## Testing
893920
894921
For testing, it might be useful to check how state machine reacts on events from particular state. There

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/TransitionBuilder.kt

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package ru.nsk.kstatemachine
22

3-
import ru.nsk.kstatemachine.TransitionDirectionProducerPolicy.CollectTargetStatesPolicy
4-
import ru.nsk.kstatemachine.TransitionDirectionProducerPolicy.DefaultPolicy
3+
import ru.nsk.kstatemachine.TransitionDirectionProducerPolicy.*
54

65
@StateMachineDslMarker
76
abstract class TransitionBuilder<E : Event>(protected val name: String?, protected val sourceState: IState) {
@@ -24,12 +23,13 @@ abstract class GuardedTransitionBuilder<E : Event, S : IState>(name: String?, so
2423
override fun build(): Transition<E> {
2524
val direction: TransitionDirectionProducer<E> = {
2625
when (it) {
27-
is DefaultPolicy<E> ->
26+
is DefaultPolicy ->
2827
if (it.eventAndArgument.guard())
2928
it.targetStateOrStay(targetState)
3029
else
3130
noTransition()
32-
is CollectTargetStatesPolicy<E> -> it.targetStateOrStay(targetState)
31+
is CollectTargetStatesPolicy,
32+
is UnsafeCollectTargetStatesPolicy -> it.targetStateOrStay(targetState)
3333
}
3434
}
3535

@@ -44,14 +44,15 @@ abstract class GuardedTransitionOnBuilder<E : Event, S : IState>(name: String?,
4444
lateinit var targetState: suspend EventAndArgument<E>.() -> S
4545

4646
override fun build(): Transition<E> {
47-
val direction: TransitionDirectionProducer<E> = {
48-
when (it) {
49-
is DefaultPolicy<E> ->
50-
if (it.eventAndArgument.guard())
51-
it.targetState(it.eventAndArgument.targetState())
47+
val direction: TransitionDirectionProducer<E> = { policy ->
48+
when (policy) {
49+
is DefaultPolicy ->
50+
if (policy.eventAndArgument.guard())
51+
policy.targetState(policy.eventAndArgument.targetState())
5252
else
5353
noTransition()
54-
is CollectTargetStatesPolicy<E> -> noTransition()
54+
is CollectTargetStatesPolicy -> noTransition()
55+
is UnsafeCollectTargetStatesPolicy -> policy.targetState(policy.eventAndArgument.targetState())
5556
}
5657
}
5758

@@ -66,10 +67,11 @@ class ConditionalTransitionBuilder<E : Event>(name: String?, sourceState: IState
6667
lateinit var direction: suspend EventAndArgument<E>.() -> TransitionDirection
6768

6869
override fun build(): Transition<E> {
69-
val direction: TransitionDirectionProducer<E> = {
70-
when (it) {
71-
is DefaultPolicy<E> -> it.eventAndArgument.direction()
72-
is CollectTargetStatesPolicy<E> -> noTransition()
70+
val direction: TransitionDirectionProducer<E> = { policy ->
71+
when (policy) {
72+
is DefaultPolicy -> policy.eventAndArgument.direction()
73+
is CollectTargetStatesPolicy -> noTransition()
74+
is UnsafeCollectTargetStatesPolicy -> policy.eventAndArgument.direction()
7375
}
7476
}
7577

@@ -98,14 +100,15 @@ class DataGuardedTransitionBuilder<E : DataEvent<D>, D : Any>(name: String?, sou
98100

99101
override fun build(): Transition<E> {
100102
require(this::targetState.isInitialized) { "targetState should be set in this transition builder" }
101-
val direction: TransitionDirectionProducer<E> = {
102-
when (it) {
103-
is DefaultPolicy<E> ->
104-
if (it.eventAndArgument.guard())
105-
it.targetState(targetState)
103+
val direction: TransitionDirectionProducer<E> = { policy ->
104+
when (policy) {
105+
is DefaultPolicy ->
106+
if (policy.eventAndArgument.guard())
107+
policy.targetState(targetState)
106108
else
107109
noTransition()
108-
is CollectTargetStatesPolicy<E> -> it.targetState(targetState)
110+
is CollectTargetStatesPolicy,
111+
is UnsafeCollectTargetStatesPolicy -> policy.targetState(targetState)
109112
}
110113
}
111114

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/TransitionDirection.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,30 @@ typealias ResolvedTransition<E> = Pair<InternalTransition<E>, TransitionDirectio
114114
internal typealias TransitionDirectionProducer<E> = suspend (TransitionDirectionProducerPolicy<E>) -> TransitionDirection
115115

116116
sealed class TransitionDirectionProducerPolicy<E : Event> {
117+
/**
118+
* Standard behaviour, calls all lambdas and resolves target states
119+
*/
117120
internal class DefaultPolicy<E : Event>(val eventAndArgument: EventAndArgument<E>) :
118121
TransitionDirectionProducerPolicy<E>() {
119122
override suspend fun targetState(targetState: IState) = eventAndArgument.targetState(targetState)
120123
override suspend fun targetStateOrStay(targetState: IState?) = targetState?.let { targetState(it) } ?: stay()
121124
}
122125

123126
/**
124-
* TODO find the way to collect target states of conditional transitions
127+
* Does not call conditional lambdas, gets only non-conditional target states
128+
*/
129+
internal class CollectTargetStatesPolicy<E : Event> :
130+
TransitionDirectionProducerPolicy<E>() {
131+
override suspend fun targetState(targetState: IState) = unresolvedTargetState(targetState)
132+
override suspend fun targetStateOrStay(targetState: IState?) = targetState?.let { targetState(it) } ?: stay()
133+
}
134+
135+
/**
136+
* Calls lambdas to get unresolved target states,
137+
* this may fail in runtime depending on user defined lambda behaviour
125138
*/
126-
internal class CollectTargetStatesPolicy<E : Event> : TransitionDirectionProducerPolicy<E>() {
139+
internal class UnsafeCollectTargetStatesPolicy<E : Event>(val eventAndArgument: EventAndArgument<E>) :
140+
TransitionDirectionProducerPolicy<E>() {
127141
override suspend fun targetState(targetState: IState) = unresolvedTargetState(targetState)
128142
override suspend fun targetStateOrStay(targetState: IState?) = targetState?.let { targetState(it) } ?: stay()
129143
}

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/visitors/ExportPlantUmlVisitor.kt

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,65 @@ package ru.nsk.kstatemachine.visitors
22

33
import ru.nsk.kstatemachine.*
44
import ru.nsk.kstatemachine.TransitionDirectionProducerPolicy.CollectTargetStatesPolicy
5+
import ru.nsk.kstatemachine.TransitionDirectionProducerPolicy.UnsafeCollectTargetStatesPolicy
6+
import ru.nsk.kstatemachine.visitors.CompatibilityFormat.MERMAID
7+
import ru.nsk.kstatemachine.visitors.CompatibilityFormat.PLANT_UML
8+
9+
/**
10+
* This object will be unsafely cast to any kind of [Event],
11+
* causing runtime failures if user defined (conditional) lambdas will touch this object.
12+
*/
13+
internal object ExportPlantUmlEvent : Event
14+
15+
internal enum class CompatibilityFormat { PLANT_UML, MERMAID }
516

617
/**
718
* Export state machine to Plant UML language format.
819
* @see <a href="https://plantuml.com/ru/state-diagram">Plant UML state diagram</a>
920
*
10-
* Conditional transitions currently are not supported.
21+
* Conditional transitions are partly supported with [unsafeCallConditionalLambdas] flag.
1122
*/
12-
internal class ExportPlantUmlVisitor(private val showEventLabels: Boolean) : CoVisitor {
23+
internal class ExportPlantUmlVisitor(
24+
private val format: CompatibilityFormat,
25+
private val showEventLabels: Boolean,
26+
private val unsafeCallConditionalLambdas: Boolean,
27+
) : CoVisitor {
1328
private val builder = StringBuilder()
1429
private var indent = 0
1530
private val crossLevelTransitions = mutableListOf<String>()
1631

1732
fun export() = builder.toString()
1833

1934
override suspend fun visit(machine: StateMachine) {
20-
line("@startuml")
21-
line("hide empty description")
35+
when (format) {
36+
PLANT_UML -> {
37+
line("@startuml")
38+
line("hide empty description")
39+
}
40+
MERMAID -> line("stateDiagram-v2")
41+
}
2242

2343
processStateBody(machine)
2444
crossLevelTransitions.forEach { line(it) }
2545

26-
line("@enduml")
46+
if (format == PLANT_UML)
47+
line("@enduml")
2748
}
2849

2950
override suspend fun visit(state: IState) {
3051
if (state.states.isEmpty()) {
3152
when (state) {
3253
is HistoryState, is UndoState -> return
33-
is RedirectPseudoState -> line("state ${state.graphName()} $CHOICE")
54+
is RedirectPseudoState -> {
55+
val stateName = state.graphName()
56+
line("state $stateName $CHOICE")
57+
if (unsafeCallConditionalLambdas) {
58+
val targetState = state.resolveTargetState(
59+
EventAndArgument(ExportPlantUmlEvent, null)
60+
) as InternalState
61+
crossLevelTransitions += "$stateName --> ${targetState.targetGraphName()}"
62+
}
63+
}
3464
else -> line("state ${state.graphName()}")
3565
}
3666
} else {
@@ -57,26 +87,25 @@ internal class ExportPlantUmlVisitor(private val showEventLabels: Boolean) : CoV
5787
val sourceState = transition.sourceState.graphName()
5888

5989
@Suppress("UNCHECKED_CAST")
60-
val targetStates = transition.produceTargetStateDirection(CollectTargetStatesPolicy()).targetStates
90+
val targetStates = transition.produceTargetStateDirection(makeDirectionProducerPolicy()).targetStates
6191
as Set<InternalState>
62-
val targetState = targetStates.firstOrNull() ?: return // fixme iterate over all
92+
targetStates.forEach { targetState -> // actually plantUml may not understand multiple transitions
93+
val transitionString = "$sourceState --> ${targetState.targetGraphName()}${transitionLabel(transition)}"
6394

64-
val graphName = if (targetState is HistoryState) {
65-
val prefix = targetState.requireInternalParent().graphName()
66-
when (targetState.historyType) {
67-
HistoryType.SHALLOW -> "$prefix$SHALLOW_HISTORY"
68-
HistoryType.DEEP -> "$prefix$DEEP_HISTORY"
69-
}
70-
} else {
71-
targetState.graphName()
95+
if (transition.sourceState.isNeighbor(targetState))
96+
line(transitionString)
97+
else
98+
crossLevelTransitions += transitionString
7299
}
100+
}
73101

74-
val transitionString = "$sourceState --> $graphName${transitionLabel(transition)}"
75-
76-
if (transition.sourceState.isNeighbor(targetState))
77-
line(transitionString)
78-
else
79-
crossLevelTransitions.add(transitionString)
102+
private fun <E : Event> makeDirectionProducerPolicy(): TransitionDirectionProducerPolicy<E> {
103+
return if (unsafeCallConditionalLambdas) {
104+
@Suppress("UNCHECKED_CAST") // this is unsafe by design
105+
UnsafeCollectTargetStatesPolicy(EventAndArgument(ExportPlantUmlEvent as E, null))
106+
} else {
107+
CollectTargetStatesPolicy()
108+
}
80109
}
81110

82111
private suspend fun processStateBody(state: IState) {
@@ -119,23 +148,49 @@ internal class ExportPlantUmlVisitor(private val showEventLabels: Boolean) : CoV
119148
const val SHALLOW_HISTORY = "[H]"
120149
const val DEEP_HISTORY = "[H*]"
121150
const val CHOICE = "<<choice>>"
151+
122152
fun IState.graphName(): String {
123153
val name = name?.replace(" ", "_") ?: "State${hashCode()}"
124154
return if (this !is StateMachine) name else "${name}_StateMachine"
125155
}
126156

157+
fun InternalState.targetGraphName(): String {
158+
return if (this is HistoryState) {
159+
val prefix = requireInternalParent().graphName()
160+
when (historyType) {
161+
HistoryType.SHALLOW -> "$prefix$SHALLOW_HISTORY"
162+
HistoryType.DEEP -> "$prefix$DEEP_HISTORY"
163+
}
164+
} else {
165+
graphName()
166+
}
167+
}
168+
127169
fun label(text: String?) = if (!text.isNullOrBlank()) " : $text" else ""
128170
}
129171
}
130172

131-
suspend fun StateMachine.exportToPlantUml(showEventLabels: Boolean = false) =
132-
with(ExportPlantUmlVisitor(showEventLabels)) {
133-
accept(this)
134-
export()
135-
}
173+
/**
174+
* Export [StateMachine] to PlantUML state diagram
175+
* @see <a href="https://plantuml.com/">PlantUML</a>
176+
*
177+
* [unsafeCallConditionalLambdas] will call conditional lambdas which can touch application data,
178+
* this may give more complete output, but may be not safe.
179+
*/
180+
suspend fun StateMachine.exportToPlantUml(
181+
showEventLabels: Boolean = false,
182+
unsafeCallConditionalLambdas: Boolean = false,
183+
) = with(ExportPlantUmlVisitor(PLANT_UML, showEventLabels, unsafeCallConditionalLambdas)) {
184+
accept(this)
185+
export()
186+
}
136187

137-
fun StateMachine.exportToPlantUmlBlocking(showEventLabels: Boolean = false) = coroutineAbstraction.runBlocking {
138-
with(ExportPlantUmlVisitor(showEventLabels)) {
188+
/** Blocking analog for [exportToPlantUml] */
189+
fun StateMachine.exportToPlantUmlBlocking(
190+
showEventLabels: Boolean = false,
191+
unsafeCallConditionalLambdas: Boolean = false,
192+
) = coroutineAbstraction.runBlocking {
193+
with(ExportPlantUmlVisitor(PLANT_UML, showEventLabels, unsafeCallConditionalLambdas)) {
139194
accept(this)
140195
export()
141196
}

0 commit comments

Comments
 (0)