512 lines
12 KiB
Go
512 lines
12 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package diagram
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// stylePair is one key:value declaration of a classDef, style or
|
||
|
|
// linkStyle statement.
|
||
|
|
type stylePair struct {
|
||
|
|
key string
|
||
|
|
value string
|
||
|
|
}
|
||
|
|
|
||
|
|
type flowNode struct {
|
||
|
|
id string
|
||
|
|
label string
|
||
|
|
shape string
|
||
|
|
classes []string
|
||
|
|
styles []stylePair
|
||
|
|
}
|
||
|
|
|
||
|
|
type flowEdge struct {
|
||
|
|
from, to int
|
||
|
|
kind string
|
||
|
|
label string
|
||
|
|
styles []stylePair
|
||
|
|
}
|
||
|
|
|
||
|
|
type flowSubgraph struct {
|
||
|
|
id, title, dir string
|
||
|
|
nodes []int
|
||
|
|
depth int
|
||
|
|
}
|
||
|
|
|
||
|
|
type flowDiagram struct {
|
||
|
|
dir string
|
||
|
|
nodes []*flowNode
|
||
|
|
index map[string]int
|
||
|
|
edges []*flowEdge
|
||
|
|
subgraphs []*flowSubgraph
|
||
|
|
classes map[string][]stylePair
|
||
|
|
linkDefault []stylePair
|
||
|
|
linkByIndex map[int][]stylePair
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseFlowchart(lines []string, dir string) (*flowDiagram, error) {
|
||
|
|
d := &flowDiagram{
|
||
|
|
dir: dir,
|
||
|
|
index: map[string]int{},
|
||
|
|
classes: map[string][]stylePair{},
|
||
|
|
linkByIndex: map[int][]stylePair{},
|
||
|
|
}
|
||
|
|
var stack []*flowSubgraph
|
||
|
|
for n, line := range lines {
|
||
|
|
if err := d.parseFlowStatement(line, &stack, n+2); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(stack) > 0 {
|
||
|
|
return nil, fmt.Errorf("diagram: flowchart line %d: subgraph %q has no end", len(lines)+1, stack[len(stack)-1].id)
|
||
|
|
}
|
||
|
|
return d, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseFlowStatement(line string, stack *[]*flowSubgraph, lineNo int) error {
|
||
|
|
sc := &scanner{src: []rune(line)}
|
||
|
|
word := sc.word()
|
||
|
|
switch strings.ToLower(word) {
|
||
|
|
case "subgraph":
|
||
|
|
return d.parseSubgraph(sc, stack, lineNo)
|
||
|
|
case "end":
|
||
|
|
if len(*stack) == 0 {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: end without subgraph", lineNo)
|
||
|
|
}
|
||
|
|
*stack = (*stack)[:len(*stack)-1]
|
||
|
|
return nil
|
||
|
|
case "direction":
|
||
|
|
sc.skipSpaces()
|
||
|
|
dir := strings.ToUpper(sc.rest())
|
||
|
|
if !flowDirections[dir] {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: unknown flow direction %q", lineNo, dir)
|
||
|
|
}
|
||
|
|
if len(*stack) > 0 {
|
||
|
|
(*stack)[len(*stack)-1].dir = dir
|
||
|
|
} else {
|
||
|
|
d.dir = dir
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
case "classdef":
|
||
|
|
return d.parseClassDef(sc, lineNo)
|
||
|
|
case "class":
|
||
|
|
return d.parseClass(sc, lineNo)
|
||
|
|
case "style":
|
||
|
|
return d.parseStyle(sc, lineNo)
|
||
|
|
case "linkstyle":
|
||
|
|
return d.parseLinkStyle(sc, lineNo)
|
||
|
|
case "click":
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: click is unsupported, the SVG carries no interactivity", lineNo)
|
||
|
|
}
|
||
|
|
sc.pos = 0
|
||
|
|
return d.parseChain(sc, *stack, lineNo)
|
||
|
|
}
|
||
|
|
|
||
|
|
// node returns the index of a node, creating it when it first appears.
|
||
|
|
// The innermost open subgraph claims a node at its first appearance.
|
||
|
|
func (d *flowDiagram) node(id, label, shape string, stack []*flowSubgraph) int {
|
||
|
|
if i, ok := d.index[id]; ok {
|
||
|
|
if shape != "" {
|
||
|
|
d.nodes[i].shape = shape
|
||
|
|
d.nodes[i].label = label
|
||
|
|
}
|
||
|
|
return i
|
||
|
|
}
|
||
|
|
if label == "" {
|
||
|
|
label = id
|
||
|
|
}
|
||
|
|
if shape == "" {
|
||
|
|
shape = "rect"
|
||
|
|
}
|
||
|
|
n := &flowNode{id: id, label: label, shape: shape}
|
||
|
|
d.nodes = append(d.nodes, n)
|
||
|
|
i := len(d.nodes) - 1
|
||
|
|
d.index[id] = i
|
||
|
|
if len(stack) > 0 {
|
||
|
|
sg := stack[len(stack)-1]
|
||
|
|
sg.nodes = append(sg.nodes, i)
|
||
|
|
}
|
||
|
|
return i
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseChain(sc *scanner, stack []*flowSubgraph, lineNo int) error {
|
||
|
|
left, err := d.parseNodeList(sc, stack, lineNo)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for {
|
||
|
|
sc.skipSpaces()
|
||
|
|
if !sc.atEdgeChar() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
kind, label, err := sc.parseEdge(lineNo)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
right, err := d.parseNodeList(sc, stack, lineNo)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for _, from := range left {
|
||
|
|
for _, to := range right {
|
||
|
|
d.edges = append(d.edges, &flowEdge{from: from, to: to, kind: kind, label: label})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
left = right
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseNodeList reads one or more nodes separated by &. A missing node is
|
||
|
|
// an error: an edge must land somewhere.
|
||
|
|
func (d *flowDiagram) parseNodeList(sc *scanner, stack []*flowSubgraph, lineNo int) ([]int, error) {
|
||
|
|
var list []int
|
||
|
|
for {
|
||
|
|
i, err := d.parseNode(sc, stack, lineNo)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
list = append(list, i)
|
||
|
|
sc.skipSpaces()
|
||
|
|
if sc.peek() == '&' {
|
||
|
|
sc.pos++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return list, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseNode(sc *scanner, stack []*flowSubgraph, lineNo int) (int, error) {
|
||
|
|
sc.skipSpaces()
|
||
|
|
var id strings.Builder
|
||
|
|
for !sc.eof() && isIDRune(sc.peek()) {
|
||
|
|
id.WriteRune(sc.peek())
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
if id.Len() == 0 {
|
||
|
|
return 0, fmt.Errorf("diagram: flowchart line %d: expected a node", lineNo)
|
||
|
|
}
|
||
|
|
label, shape := sc.parseShape()
|
||
|
|
return d.node(id.String(), label, shape, stack), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseShape reads an optional shape with its label.
|
||
|
|
func (sc *scanner) parseShape() (string, string) {
|
||
|
|
switch {
|
||
|
|
case sc.hasPrefix("[["):
|
||
|
|
return sc.bracketed("[[", "]]"), "sub"
|
||
|
|
case sc.hasPrefix("(["):
|
||
|
|
return sc.bracketed("([", "])"), "stadium"
|
||
|
|
case sc.hasPrefix("(("):
|
||
|
|
return sc.bracketed("((", "))"), "circle"
|
||
|
|
case sc.hasPrefix("["):
|
||
|
|
return sc.bracketed("[", "]"), "rect"
|
||
|
|
case sc.hasPrefix("{{"):
|
||
|
|
return sc.bracketed("{{", "}}"), "hex"
|
||
|
|
case sc.hasPrefix("{"):
|
||
|
|
return sc.bracketed("{", "}"), "diamond"
|
||
|
|
case sc.hasPrefix("("):
|
||
|
|
return sc.bracketed("(", ")"), "round"
|
||
|
|
case sc.hasPrefix(">"):
|
||
|
|
return sc.bracketed(">", "]"), "asym"
|
||
|
|
}
|
||
|
|
return "", ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// bracketed consumes the opener and reads until the closer, honouring
|
||
|
|
// double quotes. A label that is exactly a quoted string loses its
|
||
|
|
// quotes.
|
||
|
|
func (sc *scanner) bracketed(open, close string) string {
|
||
|
|
sc.pos += len(open)
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() {
|
||
|
|
if sc.peek() == '"' {
|
||
|
|
sc.pos++
|
||
|
|
for !sc.eof() && sc.peek() != '"' {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
sc.pos++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if sc.hasPrefix(close) {
|
||
|
|
label := string(sc.src[start:sc.pos])
|
||
|
|
sc.pos += len(close)
|
||
|
|
return unquote(strings.TrimSpace(label))
|
||
|
|
}
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
return unquote(strings.TrimSpace(string(sc.src[start:])))
|
||
|
|
}
|
||
|
|
|
||
|
|
func unquote(s string) string {
|
||
|
|
if len(s) >= 2 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
|
||
|
|
return s[1 : len(s)-1]
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseSubgraph(sc *scanner, stack *[]*flowSubgraph, lineNo int) error {
|
||
|
|
sc.skipSpaces()
|
||
|
|
id := sc.word()
|
||
|
|
title := id
|
||
|
|
sc.skipSpaces()
|
||
|
|
switch {
|
||
|
|
case id == "":
|
||
|
|
id = fmt.Sprintf("subgraph-%d", len(d.subgraphs))
|
||
|
|
title = id
|
||
|
|
case sc.hasPrefix("["):
|
||
|
|
title = sc.bracketed("[", "]")
|
||
|
|
default:
|
||
|
|
rest := strings.TrimSpace(sc.rest())
|
||
|
|
if rest != "" {
|
||
|
|
id = id + " " + rest
|
||
|
|
title = id
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sg := &flowSubgraph{id: id, title: title, depth: len(*stack)}
|
||
|
|
d.subgraphs = append(d.subgraphs, sg)
|
||
|
|
*stack = append(*stack, sg)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseClassDef(sc *scanner, lineNo int) error {
|
||
|
|
sc.skipSpaces()
|
||
|
|
name := sc.word()
|
||
|
|
if name == "" {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: classDef needs a name", lineNo)
|
||
|
|
}
|
||
|
|
pairs, err := parseStylePairs(sc.rest())
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||
|
|
}
|
||
|
|
d.classes[name] = append(d.classes[name], pairs...)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseClass(sc *scanner, lineNo int) error {
|
||
|
|
sc.skipSpaces()
|
||
|
|
ids := strings.Split(sc.word(), ",")
|
||
|
|
sc.skipSpaces()
|
||
|
|
names := strings.Split(strings.TrimSpace(sc.rest()), ",")
|
||
|
|
if len(ids) == 0 || ids[0] == "" || len(names) == 0 || names[0] == "" {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: class needs nodes and a class name", lineNo)
|
||
|
|
}
|
||
|
|
for _, id := range ids {
|
||
|
|
id = strings.TrimSpace(id)
|
||
|
|
if id == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
i, ok := d.index[id]
|
||
|
|
if !ok {
|
||
|
|
i = d.node(id, "", "", nil)
|
||
|
|
}
|
||
|
|
for _, name := range names {
|
||
|
|
name = strings.TrimSpace(name)
|
||
|
|
if name != "" {
|
||
|
|
d.nodes[i].classes = append(d.nodes[i].classes, name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseStyle(sc *scanner, lineNo int) error {
|
||
|
|
sc.skipSpaces()
|
||
|
|
id := sc.word()
|
||
|
|
if id == "" {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: style needs a node", lineNo)
|
||
|
|
}
|
||
|
|
pairs, err := parseStylePairs(sc.rest())
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||
|
|
}
|
||
|
|
i, ok := d.index[id]
|
||
|
|
if !ok {
|
||
|
|
i = d.node(id, "", "", nil)
|
||
|
|
}
|
||
|
|
d.nodes[i].styles = append(d.nodes[i].styles, pairs...)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *flowDiagram) parseLinkStyle(sc *scanner, lineNo int) error {
|
||
|
|
sc.skipSpaces()
|
||
|
|
target := sc.word()
|
||
|
|
rest := sc.rest()
|
||
|
|
pairs, err := parseStylePairs(rest)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||
|
|
}
|
||
|
|
if strings.ToLower(target) == "default" {
|
||
|
|
d.linkDefault = append(d.linkDefault, pairs...)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
for part := range strings.SplitSeq(target, ",") {
|
||
|
|
var n int
|
||
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(part), "%d", &n); err != nil {
|
||
|
|
return fmt.Errorf("diagram: flowchart line %d: linkStyle needs an index or default", lineNo)
|
||
|
|
}
|
||
|
|
d.linkByIndex[n] = append(d.linkByIndex[n], pairs...)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseStylePairs reads comma separated key:value declarations.
|
||
|
|
func parseStylePairs(s string) ([]stylePair, error) {
|
||
|
|
var pairs []stylePair
|
||
|
|
for part := range strings.SplitSeq(s, ",") {
|
||
|
|
part = strings.TrimSpace(part)
|
||
|
|
if part == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
key, value, found := strings.Cut(part, ":")
|
||
|
|
if !found {
|
||
|
|
return nil, fmt.Errorf("expected key:value, got %q", part)
|
||
|
|
}
|
||
|
|
pairs = append(pairs, stylePair{key: strings.TrimSpace(key), value: unquote(strings.TrimSpace(value))})
|
||
|
|
}
|
||
|
|
if len(pairs) == 0 {
|
||
|
|
return nil, fmt.Errorf("expected style declarations")
|
||
|
|
}
|
||
|
|
return pairs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func isIDRune(r rune) bool {
|
||
|
|
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_'
|
||
|
|
}
|
||
|
|
|
||
|
|
// scanner walks one statement.
|
||
|
|
type scanner struct {
|
||
|
|
src []rune
|
||
|
|
pos int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (sc *scanner) eof() bool { return sc.pos >= len(sc.src) }
|
||
|
|
|
||
|
|
func (sc *scanner) peek() rune {
|
||
|
|
if sc.eof() {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return sc.src[sc.pos]
|
||
|
|
}
|
||
|
|
|
||
|
|
func (sc *scanner) skipSpaces() {
|
||
|
|
for !sc.eof() && (sc.peek() == ' ' || sc.peek() == '\t') {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (sc *scanner) rest() string { return string(sc.src[sc.pos:]) }
|
||
|
|
|
||
|
|
func (sc *scanner) word() string {
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() && sc.peek() != ' ' && sc.peek() != '\t' {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
return string(sc.src[start:sc.pos])
|
||
|
|
}
|
||
|
|
|
||
|
|
func (sc *scanner) hasPrefix(s string) bool {
|
||
|
|
runes := []rune(s)
|
||
|
|
if sc.pos+len(runes) > len(sc.src) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
for i, r := range runes {
|
||
|
|
if sc.src[sc.pos+i] != r {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (sc *scanner) atEdgeChar() bool {
|
||
|
|
c := sc.peek()
|
||
|
|
return c == '-' || c == '.' || c == '='
|
||
|
|
}
|
||
|
|
|
||
|
|
func isEdgeRune(r rune) bool { return r == '-' || r == '.' || r == '=' }
|
||
|
|
|
||
|
|
// edgeRun consumes a run of edge characters and returns it.
|
||
|
|
func (sc *scanner) edgeRun() string {
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() && isEdgeRune(sc.peek()) {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
return string(sc.src[start:sc.pos])
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseEdge reads one edge token: a run of -, . and = characters, an
|
||
|
|
// optional arrow head, and a label either in pipes or as text between two
|
||
|
|
// runs.
|
||
|
|
func (sc *scanner) parseEdge(lineNo int) (kind, label string, err error) {
|
||
|
|
runs := sc.edgeRun()
|
||
|
|
arrow := false
|
||
|
|
switch {
|
||
|
|
case sc.peek() == '|':
|
||
|
|
sc.pos++
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() && sc.peek() != '|' {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
label = unquote(strings.TrimSpace(string(sc.src[start:sc.pos])))
|
||
|
|
if !sc.eof() {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
case sc.peek() == '>':
|
||
|
|
sc.pos++
|
||
|
|
arrow = true
|
||
|
|
if sc.peek() == '|' {
|
||
|
|
// A pipe label behind the arrow head: -->|text|
|
||
|
|
sc.pos++
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() && sc.peek() != '|' {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
label = unquote(strings.TrimSpace(string(sc.src[start:sc.pos])))
|
||
|
|
if !sc.eof() {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
save := sc.pos
|
||
|
|
sc.skipSpaces()
|
||
|
|
if !sc.eof() && !isEdgeRune(sc.peek()) {
|
||
|
|
start := sc.pos
|
||
|
|
for !sc.eof() && !isEdgeRune(sc.peek()) {
|
||
|
|
sc.pos++
|
||
|
|
}
|
||
|
|
second := sc.edgeRun()
|
||
|
|
if second == "" {
|
||
|
|
// No closing run: the words ahead are the next node, not a
|
||
|
|
// label.
|
||
|
|
sc.pos = save
|
||
|
|
} else {
|
||
|
|
label = unquote(strings.TrimSpace(string(sc.src[start : sc.pos-len(second)])))
|
||
|
|
runs += second
|
||
|
|
if sc.peek() == '>' {
|
||
|
|
sc.pos++
|
||
|
|
arrow = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
sc.pos = save
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(runs) < 2 || !arrow && len(runs) < 3 {
|
||
|
|
return "", "", fmt.Errorf("diagram: flowchart line %d: unfinished edge token", lineNo)
|
||
|
|
}
|
||
|
|
switch {
|
||
|
|
case strings.Contains(runs, "="):
|
||
|
|
kind = "thick"
|
||
|
|
case strings.Contains(runs, "."):
|
||
|
|
kind = "dotted"
|
||
|
|
default:
|
||
|
|
kind = "solid"
|
||
|
|
}
|
||
|
|
if arrow {
|
||
|
|
kind += "-arrow"
|
||
|
|
}
|
||
|
|
return kind, label, nil
|
||
|
|
}
|