diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 590a767..82ee8cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - go-version: [1.21.x, 1.22.x] + go-version: [1.24.x, 1.25.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/README.md b/README.md index f07c0c4..4b70e17 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ This can then be rendered by tools that support the DOT graph language, such as This is the complete Phone Call graph as builded in `example_test.go`. -![Phone Call graph](assets/phone-graph.png?raw=true "Phone Call complete DOT") +![Phone Call graph](assets/phone-graph.svg?raw=true "Phone Call complete DOT") ## Project Goals diff --git a/assets/phone-graph.png b/assets/phone-graph.png deleted file mode 100644 index 419eef2..0000000 Binary files a/assets/phone-graph.png and /dev/null differ diff --git a/assets/phone-graph.svg b/assets/phone-graph.svg new file mode 100644 index 0000000..918ec5b --- /dev/null +++ b/assets/phone-graph.svg @@ -0,0 +1,115 @@ + + +%0 + + +cluster_Connected + +Substates of +Connected + + + +legend +šŸ”’ Internal + + + +init + + + + +OffHook + +OffHook + + + +init->OffHook + + + + + +Ringing + +Ringing + + + +OffHook->Ringing + + +CallDialed / func1 + + + +Connected + +Connected +---------- +entry / startCallTimer +exit / func2 + + + +Connected->OffHook + + +LeftMessage + + + +Connected->Connected + + +šŸ”’ MuteMicrophone +šŸ”’ SetVolume +šŸ”’ UnmuteMicrophone + + + +OnHold + +OnHold +---------- +exit / func6 + + + +Connected->OnHold + + +PlacedOnHold + + + +OnHold->Connected + + +TakenOffHold + + + +PhoneDestroyed + +PhoneDestroyed + + + +OnHold->PhoneDestroyed + + +PhoneHurledAgainstWall + + + +Ringing->Connected + + +CallConnected + + + \ No newline at end of file diff --git a/go.mod b/go.mod index 8a39846..16db0de 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/qmuntal/stateless -go 1.19 +go 1.24 diff --git a/graph.go b/graph.go index 3d2139f..399e30e 100644 --- a/graph.go +++ b/graph.go @@ -3,15 +3,13 @@ package stateless import ( "context" "fmt" - "html" - "sort" + "slices" "strings" "text/template" "unicode" ) -type graph struct { -} +type graph struct{} type transitionLabel struct { reentry []string @@ -20,18 +18,36 @@ type transitionLabel struct { ignored []string } +type usedTransitionTypes struct { + hasReentry bool + hasInternal bool + hasIgnored bool +} + func (g *graph) formatStateMachine(sm *StateMachine) string { var sb strings.Builder - sb.WriteString("digraph {\n\tcompound=true;\n\tnode [shape=Mrecord];\n\trankdir=\"LR\";\n\n") + sb.WriteString("digraph {\n\tcompound=true;\n\tnode [shape=box, style=rounded];\n\trankdir=\"LR\";\n\n") stateList := make([]*stateRepresentation, 0, len(sm.stateConfig)) for _, st := range sm.stateConfig { stateList = append(stateList, st) } - sort.Slice(stateList, func(i, j int) bool { - return fmt.Sprint(stateList[i].State) < fmt.Sprint(stateList[j].State) + slices.SortFunc(stateList, func(a, b *stateRepresentation) int { + return strings.Compare(fmt.Sprint(a.State), fmt.Sprint(b.State)) }) + var used usedTransitionTypes + for _, sr := range stateList { + g.collectTransitionTypes(sr, &used) + } + g.writeLegend(used, &sb) + + initialState, err := sm.State(context.Background()) + if err == nil { + sb.WriteString("\tinit [label=\"\", shape=point];\n") + sb.WriteString(fmt.Sprintf("\tinit -> %s;\n\n", str(initialState, true))) + } + for _, sr := range stateList { if sr.Superstate == nil { g.formatOneState(&sb, sr, 1) @@ -42,22 +58,34 @@ func (g *graph) formatStateMachine(sm *StateMachine) string { dest := sm.stateConfig[sr.InitialTransitionTarget] if dest != nil { src := clusterStr(sr.State, true, true) - formatOneLine(&sb, src, str(dest.State, true), `""`) + g.formatOneLine(&sb, src, str(dest.State, true), `""`) } } } for _, sr := range stateList { g.formatAllStateTransitions(&sb, sm, sr) } - initialState, err := sm.State(context.Background()) - if err == nil { - sb.WriteString("\tinit [label=\"\", shape=point];\n") - sb.WriteString(fmt.Sprintf("\tinit -> %s\n", str(initialState, true))) - } sb.WriteString("}\n") return sb.String() } +func (g *graph) writeLegend(used usedTransitionTypes, sb *strings.Builder) { + var legendItems []string + if used.hasReentry { + legendItems = append(legendItems, "šŸ”„ Reentry") + } + if used.hasInternal { + legendItems = append(legendItems, "šŸ”’ Internal") + } + if used.hasIgnored { + legendItems = append(legendItems, "🚫 Ignored") + } + // Legend at bottom right (only if there are special transitions) + if len(legendItems) > 0 { + sb.WriteString(fmt.Sprintf("\n\tlegend [shape=none, label=\"%s\\l\"];\n\n", strings.Join(legendItems, "\\l"))) + } +} + func (g *graph) formatActions(sr *stateRepresentation) string { es := make([]string, 0, len(sr.EntryActions)+len(sr.ExitActions)+len(sr.ActivateActions)+len(sr.DeactivateActions)) for _, act := range sr.ActivateActions { @@ -74,22 +102,18 @@ func (g *graph) formatActions(sr *stateRepresentation) string { for _, act := range sr.ExitActions { es = append(es, fmt.Sprintf("exit / %s", esc(act.Description.String(), false))) } - return strings.Join(es, "\\n") + if len(es) == 0 { + return "" + } + return strings.Join(es, "\\l") + "\\l" } func (g *graph) formatOneState(sb *strings.Builder, sr *stateRepresentation, level int) { - var indent string - for i := 0; i < level; i++ { - indent += "\t" - } + indent := strings.Repeat("\t", level) sb.WriteString(fmt.Sprintf("%s%s [label=\"%s", indent, str(sr.State, true), str(sr.State, false))) act := g.formatActions(sr) if act != "" { - if len(sr.Substates) == 0 { - sb.WriteString("|") - } else { - sb.WriteString("\\n----------\\n") - } + sb.WriteString("\\n----------\\n") sb.WriteString(act) } sb.WriteString("\"];\n") @@ -116,15 +140,28 @@ func (g *graph) getEntryActions(ab []actionBehaviour, t Trigger) []string { return actions } +func (g *graph) collectTransitionTypes(sr *stateRepresentation, used *usedTransitionTypes) { + for _, triggers := range sr.TriggerBehaviours { + for _, trigger := range triggers { + switch trigger.(type) { + case *ignoredTriggerBehaviour: + used.hasIgnored = true + case *reentryTriggerBehaviour: + used.hasReentry = true + case *internalTriggerBehaviour: + used.hasInternal = true + } + } + } +} + func (g *graph) formatAllStateTransitions(sb *strings.Builder, sm *StateMachine, sr *stateRepresentation) { triggerList := make([]triggerBehaviour, 0, len(sr.TriggerBehaviours)) for _, triggers := range sr.TriggerBehaviours { triggerList = append(triggerList, triggers...) } - sort.Slice(triggerList, func(i, j int) bool { - ti := triggerList[i].GetTrigger() - tj := triggerList[j].GetTrigger() - return fmt.Sprint(ti) < fmt.Sprint(tj) + slices.SortFunc(triggerList, func(a, b triggerBehaviour) int { + return strings.Compare(fmt.Sprint(a.GetTrigger()), fmt.Sprint(b.GetTrigger())) }) type line struct { @@ -132,107 +169,79 @@ func (g *graph) formatAllStateTransitions(sb *strings.Builder, sm *StateMachine, destination State } - lines := make(map[line]transitionLabel, len(triggerList)) + lines := make(map[line]*transitionLabel, len(triggerList)) order := make([]line, 0, len(triggerList)) + getLine := func(ln line) *transitionLabel { + if lines[ln] == nil { + order = append(order, ln) + lines[ln] = &transitionLabel{} + } + return lines[ln] + } + for _, trigger := range triggerList { switch t := trigger.(type) { case *ignoredTriggerBehaviour: - ln := line{sr.State, sr.State} - if _, ok := lines[ln]; !ok { - order = append(order, ln) - } - transition := lines[ln] - transition.ignored = append(transition.ignored, formatOneTransition(t.Trigger, nil, t.Guard)) - lines[ln] = transition + ln := getLine(line{sr.State, sr.State}) + ln.ignored = append(ln.ignored, g.formatOneTransition(t.Trigger, nil, t.Guard)) case *reentryTriggerBehaviour: actions := g.getEntryActions(sr.EntryActions, t.Trigger) - ln := line{sr.State, t.Destination} - if _, ok := lines[ln]; !ok { - order = append(order, ln) - } - transition := lines[ln] - transition.reentry = append(transition.reentry, formatOneTransition(t.Trigger, actions, t.Guard)) - lines[ln] = transition + ln := getLine(line{sr.State, t.Destination}) + ln.reentry = append(ln.reentry, g.formatOneTransition(t.Trigger, actions, t.Guard)) case *internalTriggerBehaviour: actions := g.getEntryActions(sr.EntryActions, t.Trigger) - ln := line{sr.State, sr.State} - if _, ok := lines[ln]; !ok { - order = append(order, ln) - } - transition := lines[ln] - transition.internal = append(transition.internal, formatOneTransition(t.Trigger, actions, t.Guard)) - lines[ln] = transition + ln := getLine(line{sr.State, sr.State}) + ln.internal = append(ln.internal, g.formatOneTransition(t.Trigger, actions, t.Guard)) case *transitioningTriggerBehaviour: - src := sm.stateConfig[sr.State] - if src == nil { + if sm.stateConfig[sr.State] == nil { continue } - dest := sm.stateConfig[t.Destination] var actions []string - if dest != nil { + if dest := sm.stateConfig[t.Destination]; dest != nil { actions = g.getEntryActions(dest.EntryActions, t.Trigger) } - var destState State - if dest == nil { - destState = t.Destination - } else { - destState = dest.State - } - ln := line{sr.State, destState} - if _, ok := lines[ln]; !ok { - order = append(order, ln) - } - transition := lines[ln] - transition.transitioning = append(transition.transitioning, formatOneTransition(t.Trigger, actions, t.Guard)) - lines[ln] = transition + ln := getLine(line{sr.State, t.Destination}) + ln.transitioning = append(ln.transitioning, g.formatOneTransition(t.Trigger, actions, t.Guard)) case *dynamicTriggerBehaviour: // TODO: not supported yet } } for _, ln := range order { - content := lines[ln] - formatOneLine(sb, str(ln.source, true), str(ln.destination, true), toTransitionsLabel(content)) + g.formatOneLine(sb, str(ln.source, true), str(ln.destination, true), g.toTransitionsLabel(*lines[ln])) } } -func toTransitionsLabel(transitions transitionLabel) string { +func (g *graph) toTransitionsLabel(t transitionLabel) string { var sb strings.Builder - sb.WriteString(`<`) - for _, t := range transitions.transitioning { - sb.WriteString(``) - } - if len(transitions.reentry) > 0 { - sb.WriteString(``) - for _, t := range transitions.reentry { - sb.WriteString(``) - } - } - if len(transitions.internal) > 0 { - sb.WriteString(``) - for _, t := range transitions.internal { - sb.WriteString(``) + sb.WriteRune('"') + count := 0 + for _, group := range []struct { + transitions []string + prefix string + }{ + {t.transitioning, ""}, + {t.reentry, "šŸ”„ "}, + {t.internal, "šŸ”’ "}, + {t.ignored, "🚫 "}, + } { + for _, tr := range group.transitions { + if count > 0 { + sb.WriteString("\\l") + } + count++ + sb.WriteString(group.prefix) + sb.WriteString(tr) } } - if len(transitions.ignored) > 0 { - sb.WriteString(``) - for _, t := range transitions.ignored { - sb.WriteString(``) - } + if count > 1 { + sb.WriteString("\\l") } - sb.WriteString(`
`) - sb.WriteString(html.EscapeString(t)) - sb.WriteString(`
Reentry
`) - sb.WriteString(html.EscapeString(t)) - sb.WriteString(`
Internal
`) - sb.WriteString(html.EscapeString(t)) - sb.WriteString(`
Ignored
`) - sb.WriteString(html.EscapeString(t)) - sb.WriteString(`
>`) + sb.WriteRune('"') return sb.String() } -func formatOneTransition(trigger Trigger, actions []string, guards transitionGuard) string { +func (g *graph) formatOneTransition(trigger Trigger, actions []string, guards transitionGuard) string { var sb strings.Builder sb.WriteString(str(trigger, false)) if len(actions) > 0 { @@ -240,17 +249,13 @@ func formatOneTransition(trigger Trigger, actions []string, guards transitionGua sb.WriteString(strings.Join(actions, ", ")) } for _, info := range guards.Guards { - if sb.Len() > 0 { - sb.WriteString(" ") - } - sb.WriteString(fmt.Sprintf("[%s]", esc(info.Description.String(), false))) + sb.WriteString(fmt.Sprintf(" [%s]", esc(info.Description.String(), false))) } return sb.String() } -func formatOneLine(sb *strings.Builder, fromNodeName, toNodeName, label string) { - sb.WriteString(fmt.Sprintf("\t%s -> %s [label=%s", fromNodeName, toNodeName, label)) - sb.WriteString("];\n") +func (g *graph) formatOneLine(sb *strings.Builder, fromNodeName, toNodeName, label string) { + sb.WriteString(fmt.Sprintf("\t%s -> %s [label=%s];\n", fromNodeName, toNodeName, label)) } func clusterStr(state any, quote, init bool) string { diff --git a/testdata/golden/emptyWithInitial.dot b/testdata/golden/emptyWithInitial.dot index 326d099..c2b524f 100644 --- a/testdata/golden/emptyWithInitial.dot +++ b/testdata/golden/emptyWithInitial.dot @@ -1,8 +1,9 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; init [label="", shape=point]; - init -> A + init -> A; + } diff --git a/testdata/golden/phoneCall.dot b/testdata/golden/phoneCall.dot index 5b8cfb6..c7526bc 100644 --- a/testdata/golden/phoneCall.dot +++ b/testdata/golden/phoneCall.dot @@ -1,23 +1,27 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; - Connected [label="Connected\n----------\nentry / startCallTimer\nexit / func2"]; + + legend [shape=none, label="šŸ”’ Internal\l"]; + + init [label="", shape=point]; + init -> OffHook; + + Connected [label="Connected\n----------\nentry / startCallTimer\lexit / func2\l"]; subgraph cluster_Connected { label="Substates of\nConnected"; style="dashed"; - OnHold [label="OnHold|exit / func6"]; + OnHold [label="OnHold\n----------\nexit / func6\l"]; } OffHook [label="OffHook"]; Ringing [label="Ringing"]; Connected -> OffHook [label="LeftMessage"]; - Connected -> Connected [label="MuteMicrophone\nSetVolume\nUnmuteMicrophone"]; + Connected -> Connected [label="šŸ”’ MuteMicrophone\lšŸ”’ SetVolume\lšŸ”’ UnmuteMicrophone\l"]; Connected -> OnHold [label="PlacedOnHold"]; OffHook -> Ringing [label="CallDialed / func1"]; OnHold -> PhoneDestroyed [label="PhoneHurledAgainstWall"]; OnHold -> Connected [label="TakenOffHold"]; Ringing -> Connected [label="CallConnected"]; - init [label="", shape=point]; - init -> OffHook } diff --git a/testdata/golden/withGuards.dot b/testdata/golden/withGuards.dot index 59f9e0e..53031bf 100644 --- a/testdata/golden/withGuards.dot +++ b/testdata/golden/withGuards.dot @@ -1,8 +1,11 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; + init [label="", shape=point]; + init -> B; + A [label="A"]; subgraph cluster_A { label="Substates of\nA"; @@ -11,6 +14,4 @@ digraph { } A -> D [label="X [func1]"]; B -> C [label="X [func2]"]; - init [label="", shape=point]; - init -> B } diff --git a/testdata/golden/withInitialState.dot b/testdata/golden/withInitialState.dot index a429a56..bb6adce 100644 --- a/testdata/golden/withInitialState.dot +++ b/testdata/golden/withInitialState.dot @@ -1,8 +1,11 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; + init [label="", shape=point]; + init -> A; + A [label="A"]; B [label="B"]; subgraph cluster_B { @@ -20,6 +23,4 @@ digraph { "cluster_B-init" -> C [label=""]; "cluster_C-init" -> D [label=""]; A -> B [label="X"]; - init [label="", shape=point]; - init -> A } diff --git a/testdata/golden/withSubstate.dot b/testdata/golden/withSubstate.dot index 6c44536..db26379 100644 --- a/testdata/golden/withSubstate.dot +++ b/testdata/golden/withSubstate.dot @@ -1,8 +1,14 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; + + legend [shape=none, label="🚫 Ignored\l"]; + + init [label="", shape=point]; + init -> B; + A [label="A"]; C [label="C"]; subgraph cluster_C { @@ -12,8 +18,6 @@ digraph { } A -> B [label="Z"]; B -> A [label="X"]; - C -> C [label="X"]; + C -> C [label="🚫 X"]; C -> A [label="Y"]; - init [label="", shape=point]; - init -> B } diff --git a/testdata/golden/withUnicodeNames.dot b/testdata/golden/withUnicodeNames.dot index 0ceadc4..b11f710 100644 --- a/testdata/golden/withUnicodeNames.dot +++ b/testdata/golden/withUnicodeNames.dot @@ -1,8 +1,11 @@ digraph { compound=true; - node [shape=Mrecord]; + node [shape=box, style=rounded]; rankdir="LR"; + init [label="", shape=point]; + init -> Ĕ; + Ĕ [label="Ĕ"]; ų [label="ų"]; subgraph cluster_ų { @@ -26,6 +29,4 @@ digraph { "cluster_ų-init" -> 懓 [label=""]; "cluster_懓-init" -> ꬠ [label=""]; Ĕ -> ų [label="ā—µ [œ]"]; - init [label="", shape=point]; - init -> Ĕ }