diff --git a/graphviz_visualizer.go b/graphviz_visualizer.go new file mode 100644 index 0000000..4163529 --- /dev/null +++ b/graphviz_visualizer.go @@ -0,0 +1,58 @@ +package fsm + +import ( + "bytes" + "fmt" +) + +// Visualize outputs a visualization of a FSM in Graphviz format. +func Visualize(fsm *FSM) string { + var buf bytes.Buffer + + // we sort the key alphabetically to have a reproducible graph output + sortedEKeys := getSortedTransitionKeys(fsm.transitions) + sortedStateKeys, _ := getSortedStates(fsm.transitions) + + writeHeaderLine(&buf) + writeTransitions(&buf, fsm.current, sortedEKeys, fsm.transitions) + writeStates(&buf, sortedStateKeys) + writeFooter(&buf) + + return buf.String() +} + +func writeHeaderLine(buf *bytes.Buffer) { + buf.WriteString(fmt.Sprintf(`digraph fsm {`)) + buf.WriteString("\n") +} + +func writeTransitions(buf *bytes.Buffer, current string, sortedEKeys []eKey, transitions map[eKey]string) { + // make sure the current state is at top + for _, k := range sortedEKeys { + if k.src == current { + v := transitions[k] + buf.WriteString(fmt.Sprintf(` "%s" -> "%s" [ label = "%s" ];`, k.src, v, k.event)) + buf.WriteString("\n") + } + } + for _, k := range sortedEKeys { + if k.src != current { + v := transitions[k] + buf.WriteString(fmt.Sprintf(` "%s" -> "%s" [ label = "%s" ];`, k.src, v, k.event)) + buf.WriteString("\n") + } + } + + buf.WriteString("\n") +} + +func writeStates(buf *bytes.Buffer, sortedStateKeys []string) { + for _, k := range sortedStateKeys { + buf.WriteString(fmt.Sprintf(` "%s";`, k)) + buf.WriteString("\n") + } +} + +func writeFooter(buf *bytes.Buffer) { + buf.WriteString(fmt.Sprintln("}")) +} diff --git a/graphviz_visualizer_test.go b/graphviz_visualizer_test.go new file mode 100644 index 0000000..b28c476 --- /dev/null +++ b/graphviz_visualizer_test.go @@ -0,0 +1,38 @@ +package fsm + +import ( + "fmt" + "strings" + "testing" +) + +func TestGraphvizOutput(t *testing.T) { + fsmUnderTest := NewFSM( + "closed", + Events{ + {Name: "open", Src: []string{"closed"}, Dst: "open"}, + {Name: "close", Src: []string{"open"}, Dst: "closed"}, + {Name: "part-close", Src: []string{"intermediate"}, Dst: "closed"}, + }, + Callbacks{}, + ) + + got := Visualize(fsmUnderTest) + wanted := ` +digraph fsm { + "closed" -> "open" [ label = "open" ]; + "intermediate" -> "closed" [ label = "part-close" ]; + "open" -> "closed" [ label = "close" ]; + + "closed"; + "intermediate"; + "open"; +}` + normalizedGot := strings.ReplaceAll(got, "\n", "") + normalizedWanted := strings.ReplaceAll(wanted, "\n", "") + if normalizedGot != normalizedWanted { + t.Errorf("build graphivz graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) + fmt.Println([]byte(normalizedGot)) + fmt.Println([]byte(normalizedWanted)) + } +} diff --git a/mermaid_visualizer.go b/mermaid_visualizer.go new file mode 100644 index 0000000..7f2887a --- /dev/null +++ b/mermaid_visualizer.go @@ -0,0 +1,89 @@ +package fsm + +import ( + "bytes" + "fmt" +) + +const highlightingColor = "#00AA00" + +// MermaidDiagramType the type of the mermaid diagram type +type MermaidDiagramType string + +const ( + // FlowChart the diagram type for output in flowchart style (https://mermaid-js.github.io/mermaid/#/flowchart) (including current state) + FlowChart MermaidDiagramType = "flowChart" + // StateDiagram the diagram type for output in stateDiagram style (https://mermaid-js.github.io/mermaid/#/stateDiagram) + StateDiagram MermaidDiagramType = "stateDiagram" +) + +// VisualizeForMermaidWithGraphType outputs a visualization of a FSM in Mermaid format as specified by the graphType. +func VisualizeForMermaidWithGraphType(fsm *FSM, graphType MermaidDiagramType) (string, error) { + switch graphType { + case FlowChart: + return visualizeForMermaidAsFlowChart(fsm), nil + case StateDiagram: + return visualizeForMermaidAsStateDiagram(fsm), nil + default: + return "", fmt.Errorf("unknown MermaidDiagramType: %s", graphType) + } +} + +func visualizeForMermaidAsStateDiagram(fsm *FSM) string { + var buf bytes.Buffer + + sortedTransitionKeys := getSortedTransitionKeys(fsm.transitions) + + buf.WriteString("stateDiagram\n") + buf.WriteString(fmt.Sprintln(` [*] -->`, fsm.current)) + + for _, k := range sortedTransitionKeys { + v := fsm.transitions[k] + buf.WriteString(fmt.Sprintf(` %s --> %s: %s`, k.src, v, k.event)) + buf.WriteString("\n") + } + + return buf.String() +} + +// visualizeForMermaidAsFlowChart outputs a visualization of a FSM in Mermaid format (including highlighting of current state). +func visualizeForMermaidAsFlowChart(fsm *FSM) string { + var buf bytes.Buffer + + sortedTransitionKeys := getSortedTransitionKeys(fsm.transitions) + sortedStates, statesToIDMap := getSortedStates(fsm.transitions) + + writeFlowChartGraphType(&buf) + writeFlowChartStates(&buf, sortedStates, statesToIDMap) + writeFlowChartTransitions(&buf, fsm.transitions, sortedTransitionKeys, statesToIDMap) + writeFlowChartHightlightCurrent(&buf, fsm.current, statesToIDMap) + + return buf.String() +} + +func writeFlowChartGraphType(buf *bytes.Buffer) { + buf.WriteString("graph LR\n") +} + +func writeFlowChartStates(buf *bytes.Buffer, sortedStates []string, statesToIDMap map[string]string) { + for _, state := range sortedStates { + buf.WriteString(fmt.Sprintf(` %s[%s]`, statesToIDMap[state], state)) + buf.WriteString("\n") + } + + buf.WriteString("\n") +} + +func writeFlowChartTransitions(buf *bytes.Buffer, transitions map[eKey]string, sortedTransitionKeys []eKey, statesToIDMap map[string]string) { + for _, transition := range sortedTransitionKeys { + target := transitions[transition] + buf.WriteString(fmt.Sprintf(` %s --> |%s| %s`, statesToIDMap[transition.src], transition.event, statesToIDMap[target])) + buf.WriteString("\n") + } + buf.WriteString("\n") +} + +func writeFlowChartHightlightCurrent(buf *bytes.Buffer, current string, statesToIDMap map[string]string) { + buf.WriteString(fmt.Sprintf(` style %s fill:%s`, statesToIDMap[current], highlightingColor)) + buf.WriteString("\n") +} diff --git a/utils_test.go b/mermaid_visualizer_test.go similarity index 72% rename from utils_test.go rename to mermaid_visualizer_test.go index 958ce1e..0407f6b 100644 --- a/utils_test.go +++ b/mermaid_visualizer_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestGraphvizOutput(t *testing.T) { +func TestMermaidOutput(t *testing.T) { fsmUnderTest := NewFSM( "closed", Events{ @@ -17,27 +17,27 @@ func TestGraphvizOutput(t *testing.T) { Callbacks{}, ) - got := Visualize(fsmUnderTest) + got, err := VisualizeForMermaidWithGraphType(fsmUnderTest, StateDiagram) + if err != nil { + t.Errorf("got error for visualizing with type MERMAID: %s", err) + } wanted := ` -digraph fsm { - "closed" -> "open" [ label = "open" ]; - "intermediate" -> "closed" [ label = "part-close" ]; - "open" -> "closed" [ label = "close" ]; - - "closed"; - "intermediate"; - "open"; -}` +stateDiagram + [*] --> closed + closed --> open: open + intermediate --> closed: part-close + open --> closed: close +` normalizedGot := strings.ReplaceAll(got, "\n", "") normalizedWanted := strings.ReplaceAll(wanted, "\n", "") if normalizedGot != normalizedWanted { - t.Errorf("build graphivz graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) + t.Errorf("build mermaid graph failed. \nwanted \n%s\nand got \n%s\n", wanted, got) fmt.Println([]byte(normalizedGot)) fmt.Println([]byte(normalizedWanted)) } } -func TestMermaidOutput(t *testing.T) { +func TestMermaidFlowChartOutput(t *testing.T) { fsmUnderTest := NewFSM( "closed", Events{ @@ -48,16 +48,21 @@ func TestMermaidOutput(t *testing.T) { Callbacks{}, ) - got, err := VisualizeWithType(fsmUnderTest, MERMAID) + got, err := VisualizeForMermaidWithGraphType(fsmUnderTest, FlowChart) if err != nil { t.Errorf("got error for visualizing with type MERMAID: %s", err) } wanted := ` -stateDiagram - [*] --> closed - closed --> open: open - intermediate --> closed: part-close - open --> closed: close +graph LR + id0[closed] + id1[intermediate] + id2[open] + + id0 --> |open| id2 + id1 --> |part-close| id0 + id2 --> |close| id0 + + style id0 fill:#00AA00 ` normalizedGot := strings.ReplaceAll(got, "\n", "") normalizedWanted := strings.ReplaceAll(wanted, "\n", "") diff --git a/utils.go b/utils.go deleted file mode 100644 index ce5b32a..0000000 --- a/utils.go +++ /dev/null @@ -1,113 +0,0 @@ -package fsm - -import ( - "bytes" - "fmt" - "sort" -) - -// VisualizeType the type of the visualization -type VisualizeType string - -const ( - // GRAPHVIZ the type for graphviz output (http://www.webgraphviz.com/) - GRAPHVIZ VisualizeType = "graphviz" - // MERMAID the type for mermaid output (https://mermaid-js.github.io/mermaid-live-editor/) - MERMAID VisualizeType = "mermaid" -) - -// VisualizeWithType outputs a visualization of a FSM in the desired format. -// If the type is not given it defaults to GRAPHVIZ -func VisualizeWithType(fsm *FSM, visualizeType VisualizeType) (string, error) { - switch visualizeType { - case GRAPHVIZ: - return Visualize(fsm), nil - case MERMAID: - return visualizeForMermaid(fsm), nil - default: - return "", fmt.Errorf("unknown VisualizeType: %s", visualizeType) - } -} - -// visualizeForMermaid outputs a visualization of a FSM in Mermaid format. -func visualizeForMermaid(fsm *FSM) string { - var buf bytes.Buffer - - // we sort the key alphabetically to have a reproducible graph output - sortedEKeys := make([]eKey, 0) - for k := range fsm.transitions { - sortedEKeys = append(sortedEKeys, k) - } - sort.Slice(sortedEKeys, func(i, j int) bool { - return sortedEKeys[i].src < sortedEKeys[j].src - }) - - buf.WriteString("stateDiagram\n") - buf.WriteString(fmt.Sprintln(` [*] -->`, fsm.current)) - - for _, k := range sortedEKeys { - v := fsm.transitions[k] - buf.WriteString(fmt.Sprintf(` %s --> %s: %s`, k.src, v, k.event)) - buf.WriteString("\n") - } - - return buf.String() -} - -// Visualize outputs a visualization of a FSM in Graphviz format. -func Visualize(fsm *FSM) string { - var buf bytes.Buffer - - states := make(map[string]int) - - // we sort the key alphabetically to have a reproducible graph output - sortedEKeys := make([]eKey, 0) - for k := range fsm.transitions { - sortedEKeys = append(sortedEKeys, k) - } - sort.Slice(sortedEKeys, func(i, j int) bool { - return sortedEKeys[i].src < sortedEKeys[j].src - }) - - buf.WriteString(fmt.Sprintf(`digraph fsm {`)) - buf.WriteString("\n") - - // make sure the initial state is at top - for _, k := range sortedEKeys { - v := fsm.transitions[k] - if k.src == fsm.current { - states[k.src]++ - states[v]++ - buf.WriteString(fmt.Sprintf(` "%s" -> "%s" [ label = "%s" ];`, k.src, v, k.event)) - buf.WriteString("\n") - } - } - - for _, k := range sortedEKeys { - v := fsm.transitions[k] - if k.src != fsm.current { - states[k.src]++ - states[v]++ - buf.WriteString(fmt.Sprintf(` "%s" -> "%s" [ label = "%s" ];`, k.src, v, k.event)) - buf.WriteString("\n") - } - } - - buf.WriteString("\n") - - sortedStateKeys := make([]string, 0) - for k := range states { - sortedStateKeys = append(sortedStateKeys, k) - } - sort.Slice(sortedStateKeys, func(i, j int) bool { - return sortedStateKeys[i] < sortedStateKeys[j] - }) - - for _, k := range sortedStateKeys { - buf.WriteString(fmt.Sprintf(` "%s";`, k)) - buf.WriteString("\n") - } - buf.WriteString(fmt.Sprintln("}")) - - return buf.String() -} diff --git a/visualizer.go b/visualizer.go new file mode 100644 index 0000000..e241fc7 --- /dev/null +++ b/visualizer.go @@ -0,0 +1,74 @@ +package fsm + +import ( + "fmt" + "sort" +) + +// VisualizeType the type of the visualization +type VisualizeType string + +const ( + // GRAPHVIZ the type for graphviz output (http://www.webgraphviz.com/) + GRAPHVIZ VisualizeType = "graphviz" + // MERMAID the type for mermaid output (https://mermaid-js.github.io/mermaid/#/stateDiagram) in the stateDiagram form + MERMAID VisualizeType = "mermaid" + // MERMAID the type for mermaid output (https://mermaid-js.github.io/mermaid/#/stateDiagram) in the stateDiagram form + MermaidStateDiagram VisualizeType = "mermaid-state-diagram" + // MERMAID the type for mermaid output (https://mermaid-js.github.io/mermaid/#/flowchart) in the flow chart form + MermaidFlowChart VisualizeType = "mermaid-flow-chart" +) + +// VisualizeWithType outputs a visualization of a FSM in the desired format. +// If the type is not given it defaults to GRAPHVIZ +func VisualizeWithType(fsm *FSM, visualizeType VisualizeType) (string, error) { + switch visualizeType { + case GRAPHVIZ: + return Visualize(fsm), nil + case MERMAID: + return VisualizeForMermaidWithGraphType(fsm, StateDiagram) + case MermaidStateDiagram: + return VisualizeForMermaidWithGraphType(fsm, StateDiagram) + case MermaidFlowChart: + return VisualizeForMermaidWithGraphType(fsm, FlowChart) + default: + return "", fmt.Errorf("unknown VisualizeType: %s", visualizeType) + } +} + +func getSortedTransitionKeys(transitions map[eKey]string) []eKey { + // we sort the key alphabetically to have a reproducible graph output + sortedTransitionKeys := make([]eKey, 0) + + for transition := range transitions { + sortedTransitionKeys = append(sortedTransitionKeys, transition) + } + sort.Slice(sortedTransitionKeys, func(i, j int) bool { + return sortedTransitionKeys[i].src < sortedTransitionKeys[j].src + }) + + return sortedTransitionKeys +} + +func getSortedStates(transitions map[eKey]string) ([]string, map[string]string) { + statesToIDMap := make(map[string]string) + for transition, target := range transitions { + if _, ok := statesToIDMap[transition.src]; !ok { + statesToIDMap[transition.src] = "" + } + if _, ok := statesToIDMap[target]; !ok { + statesToIDMap[target] = "" + } + } + + sortedStates := make([]string, 0, len(statesToIDMap)) + for state := range statesToIDMap { + sortedStates = append(sortedStates, state) + } + sort.Strings(sortedStates) + + for i, state := range sortedStates { + statesToIDMap[state] = fmt.Sprintf("id%d", i) + } + return sortedStates, statesToIDMap +}