Files

512 lines
12 KiB
Go
Raw Permalink Normal View History

// 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
}