Files

570 lines
16 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package diagram
import (
"fmt"
"strings"
)
// Sequence layout constants, in SVG units.
const (
seqMargin = 24
seqHeaderTop = 24
seqHeaderH = 44
seqRowGap = 30
)
type seqParticipant struct {
id, name string
actor bool
x int
}
// seqItem is one row: a message, a note, a divider, a frame boundary or
// an activation change.
type seqItem struct {
kind string
from string
to string
arrow string
text string
num int
side string
over string
frame *seqFrame
act string
actOn bool
y, h int
}
// seqFrame is one frame of a block construct: from its label row to the
// row of the boundary that closes it.
type seqFrame struct {
kind, label string
colour string
first, last int
depth int
}
type seqDiagram struct {
participants []*seqParticipant
index map[string]int
items []*seqItem
frames []*seqFrame
autonumber bool
}
type seqConstruct struct {
frame *seqFrame
}
func renderSequence(lines []string, auto bool) ([]byte, error) {
d, err := parseSequence(lines, auto)
if err != nil {
return nil, err
}
return writeSequence(d), nil
}
func (d *seqDiagram) participant(id string) int {
if i, ok := d.index[id]; ok {
return i
}
p := &seqParticipant{id: id, name: id}
d.participants = append(d.participants, p)
i := len(d.participants) - 1
d.index[id] = i
return i
}
func parseSequence(lines []string, auto bool) (*seqDiagram, error) {
d := &seqDiagram{index: map[string]int{}, autonumber: auto}
var stack []*seqConstruct
for n, line := range lines {
lineNo := n + 2
if err := d.parseSeqStatement(line, &stack, lineNo); err != nil {
return nil, err
}
}
if len(stack) > 0 {
return nil, fmt.Errorf("diagram: sequence line %d: block %q has no end", len(lines)+1, stack[len(stack)-1].frame.kind)
}
return d, nil
}
var seqBlockOpeners = map[string]bool{
"alt": true, "opt": true, "loop": true, "par": true,
"critical": true, "break": true,
}
var seqBlockElse = map[string]bool{
"else": true, "and": true, "option": true,
}
func (d *seqDiagram) parseSeqStatement(line string, stack *[]*seqConstruct, lineNo int) error {
lower := strings.ToLower(line)
keyword := firstWord(lower)
switch {
case strings.HasPrefix(lower, "participant "), strings.HasPrefix(lower, "actor "):
return d.parseSeqParticipant(line, lineNo)
case keyword == "autonumber":
d.autonumber = !strings.HasSuffix(lower, "off")
return nil
case strings.HasPrefix(lower, "activate "), strings.HasPrefix(lower, "deactivate "):
on := strings.HasPrefix(lower, "activate")
id := strings.TrimSpace(line[len("activate "):])
if strings.HasPrefix(lower, "deactivate ") {
id = strings.TrimSpace(line[len("deactivate "):])
}
if id == "" {
return fmt.Errorf("diagram: sequence line %d: activation needs a participant", lineNo)
}
d.participant(id)
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: on, h: 8})
return nil
case strings.HasPrefix(lower, "note "):
return d.parseSeqNote(line, lineNo)
case strings.HasPrefix(line, "..."):
text := strings.Trim(line, ". ")
d.items = append(d.items, &seqItem{kind: "divider", text: text, h: 34})
return nil
case seqBlockOpeners[keyword] || (keyword == "rect" && strings.HasPrefix(lower, "rect ")):
text := strings.TrimSpace(line[len(keyword):])
frame := &seqFrame{kind: keyword, depth: len(*stack)}
if keyword == "rect" {
colour, err := parseRectColour(text, lineNo)
if err != nil {
return err
}
frame.colour = colour
} else {
frame.label = keyword + " " + text
}
frame.first = len(d.items)
d.frames = append(d.frames, frame)
d.items = append(d.items, &seqItem{kind: "open", frame: frame, h: 30})
*stack = append(*stack, &seqConstruct{frame: frame})
return nil
case seqBlockElse[keyword]:
if len(*stack) == 0 {
return fmt.Errorf("diagram: sequence line %d: %s outside a block", lineNo, keyword)
}
text := strings.TrimSpace(line[len(keyword):])
construct := (*stack)[len(*stack)-1]
construct.frame.last = len(d.items) - 1
frame := &seqFrame{kind: keyword, label: keyword + " " + text, depth: construct.frame.depth}
frame.first = len(d.items)
d.frames = append(d.frames, frame)
d.items = append(d.items, &seqItem{kind: "open", frame: frame, h: 30})
construct.frame = frame
return nil
case keyword == "end":
if len(*stack) == 0 {
return fmt.Errorf("diagram: sequence line %d: end without a block", lineNo)
}
construct := (*stack)[len(*stack)-1]
*stack = (*stack)[:len(*stack)-1]
construct.frame.last = len(d.items)
d.items = append(d.items, &seqItem{kind: "close", frame: construct.frame, h: 14})
return nil
}
return d.parseSeqMessage(line, lineNo)
}
func firstWord(s string) string {
w, _, _ := strings.Cut(s, " ")
return w
}
func (d *seqDiagram) parseSeqParticipant(line string, lineNo int) error {
sc := &scanner{src: []rune(line)}
sc.word() // participant or actor
actor := strings.HasPrefix(strings.ToLower(line), "actor")
sc.skipSpaces()
id := sc.word()
if id == "" {
return fmt.Errorf("diagram: sequence line %d: participant needs an id", lineNo)
}
sc.skipSpaces()
name := id
if strings.HasPrefix(sc.rest(), "as ") {
name = unquote(strings.TrimSpace(sc.rest()[3:]))
} else if strings.HasPrefix(sc.rest(), "[") {
name = sc.bracketed("[", "]")
} else if rest := strings.TrimSpace(sc.rest()); rest != "" {
name = unquote(rest)
}
i := d.participant(id)
d.participants[i].name = name
d.participants[i].actor = actor
return nil
}
func (d *seqDiagram) parseSeqNote(line string, lineNo int) error {
rest := strings.TrimSpace(line[len("note "):])
lower := strings.ToLower(rest)
var side, spec string
switch {
case strings.HasPrefix(lower, "left of "):
side, spec = "left", strings.TrimSpace(rest[len("left of "):])
case strings.HasPrefix(lower, "right of "):
side, spec = "right", strings.TrimSpace(rest[len("right of "):])
case strings.HasPrefix(lower, "over "):
side, spec = "over", strings.TrimSpace(rest[len("over "):])
default:
return fmt.Errorf("diagram: sequence line %d: note needs left of, right of or over", lineNo)
}
colon := strings.Index(spec, ":")
if colon < 0 {
return fmt.Errorf("diagram: sequence line %d: note needs a colon before its text", lineNo)
}
text := strings.TrimSpace(spec[colon+1:])
spec = strings.TrimSpace(spec[:colon])
id, over := spec, ""
if side == "over" {
if a, b, found := strings.Cut(spec, ","); found {
id, over = strings.TrimSpace(a), strings.TrimSpace(b)
}
}
if id == "" {
return fmt.Errorf("diagram: sequence line %d: note needs a participant", lineNo)
}
d.participant(id)
if over != "" {
d.participant(over)
}
d.items = append(d.items, &seqItem{kind: "note", side: side, from: id, over: over, text: text})
return nil
}
// seqArrows lists the message arrows longest first, so the scan prefers
// the long form.
var seqArrows = []string{"-->>", "-->", "->>", "-x", "--x", "->"}
// parseSeqMessage reads FROM arrow TO: text.
func (d *seqDiagram) parseSeqMessage(line string, lineNo int) error {
bestAt, bestLen := -1, 0
for _, a := range seqArrows {
if at := strings.Index(line, a); at >= 0 && (bestAt < 0 || at < bestAt || at == bestAt && len(a) > bestLen) {
if bestAt < 0 || at < bestAt || len(a) > bestLen {
bestAt, bestLen = at, len(a)
}
}
}
if bestAt < 0 {
return fmt.Errorf("diagram: sequence line %d: cannot parse %q", lineNo, line)
}
arrow := line[bestAt : bestAt+bestLen]
left := strings.TrimSpace(line[:bestAt])
rest := strings.TrimSpace(line[bestAt+bestLen:])
before, after, ok := strings.Cut(rest, ":")
if !ok {
return fmt.Errorf("diagram: sequence line %d: message needs a colon before its text", lineNo)
}
right := strings.TrimSpace(before)
text := strings.TrimSpace(after)
// Activation flags cling to the participant names.
fromAct, fromDeact := false, false
if before, ok := strings.CutSuffix(left, "+"); ok {
fromAct, left = true, before
} else if before, ok := strings.CutSuffix(left, "-"); ok {
fromDeact, left = true, before
}
left = strings.TrimSpace(left)
toAct, toDeact := false, false
right = strings.TrimSpace(right)
if after, ok := strings.CutPrefix(right, "+"); ok {
toAct, right = true, after
} else if after, ok := strings.CutPrefix(right, "-"); ok {
toDeact, right = true, after
}
if before, ok := strings.CutSuffix(right, "+"); ok {
toAct, right = true, before
} else if before, ok := strings.CutSuffix(right, "-"); ok {
toDeact, right = true, before
}
right = strings.TrimSpace(right)
if left == "" || right == "" {
return fmt.Errorf("diagram: sequence line %d: message needs two participants", lineNo)
}
d.participant(left)
d.participant(right)
num := 0
if d.autonumber {
num = d.countMessages() + 1
}
d.items = append(d.items, &seqItem{
kind: "msg", from: left, to: right, arrow: arrow, text: text, num: num,
})
if fromAct {
d.activate(left, lineNo)
}
if toAct {
d.activate(right, lineNo)
}
if fromDeact {
d.deactivate(left, lineNo)
}
if toDeact {
d.deactivate(right, lineNo)
}
return nil
}
func (d *seqDiagram) countMessages() int {
n := 0
for _, it := range d.items {
if it.kind == "msg" {
n++
}
}
return n
}
// activations collects the open and closed activation spans while the
// items lay out; the parser records the changes as items.
type seqActivation struct {
id string
y1 int
y2 int
open bool
}
func (d *seqDiagram) activate(id string, lineNo int) {
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: true, h: 8})
}
func (d *seqDiagram) deactivate(id string, lineNo int) {
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: false, h: 8})
}
// parseRectColour reads rgb(r,g,b) or #hex.
func parseRectColour(s string, lineNo int) (string, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "rgb(") && strings.HasSuffix(s, ")") {
parts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(s, "rgb("), ")"), ",")
if len(parts) != 3 {
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
}
r, g, b := 0, 0, 0
var err error
if _, err = fmt.Sscanf(strings.TrimSpace(parts[0]), "%d", &r); err != nil {
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
}
if _, err = fmt.Sscanf(strings.TrimSpace(parts[1]), "%d", &g); err != nil {
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
}
if _, err = fmt.Sscanf(strings.TrimSpace(parts[2]), "%d", &b); err != nil {
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
}
return fmt.Sprintf("#%02x%02x%02x", clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)), nil
}
if strings.HasPrefix(s, "#") && len(s) == 7 {
for i := 1; i < len(s); i++ {
c := s[i]
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
return "", fmt.Errorf("diagram: sequence line %d: bad colour %q", lineNo, s)
}
}
return s, nil
}
return "", fmt.Errorf("diagram: sequence line %d: rect needs rgb(r,g,b) or #rrggbb", lineNo)
}
func writeSequence(d *seqDiagram) []byte {
// Lanes.
laneWidth := 150
for _, p := range d.participants {
laneWidth = max(laneWidth, textWidth(p.name, 14)+80)
}
width := seqMargin*2 + laneWidth*len(d.participants)
for i, p := range d.participants {
p.x = seqMargin + laneWidth*i + laneWidth/2
}
// Rows.
y := seqMargin + seqHeaderH + seqRowGap
for _, it := range d.items {
switch it.kind {
case "note":
it.h = 16 + 18*len(labelLines(it.text))
case "msg":
it.h = 38
}
it.y = y
y += it.h + 10
}
bottom := y + 10
if len(d.items) == 0 {
bottom = seqMargin + seqHeaderH + seqRowGap + 40
}
height := bottom + seqMargin
svg := newSVGBuilder(width, height)
// Frame backgrounds, then lifelines, headers, activations, notes and
// messages, then frame outlines.
for _, f := range d.frames {
if f.colour == "" {
continue
}
x0 := seqMargin + f.depth*12
fw := width - 2*seqMargin - 2*f.depth*12
top := d.items[f.first].y + 4
low := d.items[f.last].y + d.items[f.last].h
svg.rect(x0, top, fw, low-top, 6, fmt.Sprintf(` fill="%s" fill-opacity="0.18" stroke="none"`, f.colour))
}
lifelineBottom := bottom
for _, p := range d.participants {
svg.line(p.x, seqMargin+seqHeaderH, p.x, lifelineBottom, ` stroke="#999" stroke-dasharray="5 5"`)
}
// Activations: the act items, in row order, open and close the spans.
var activations []seqActivation
open := map[string][]int{}
for _, it := range d.items {
if it.kind != "act" {
continue
}
y := it.y + it.h/2
if it.actOn {
open[it.act] = append(open[it.act], y)
} else if len(open[it.act]) > 0 {
start := open[it.act][len(open[it.act])-1]
open[it.act] = open[it.act][:len(open[it.act])-1]
activations = append(activations, seqActivation{id: it.act, y1: start, y2: y})
}
}
for _, a := range activations {
x := d.participants[d.index[a.id]].x
svg.rect(x-5, a.y1, 10, max(a.y2-a.y1, 8), 0, ` fill="#e8e8e8" stroke="#666" stroke-width="1"`)
}
// Participant headers.
for _, p := range d.participants {
attrs := ` fill="#eef2f7" stroke="#333" stroke-width="1.5"`
if p.actor {
attrs = ` fill="#e8f0e8" stroke="#333" stroke-width="2.5"`
}
w := max(laneWidth-24, textWidth(p.name, 14)+30)
svg.rect(p.x-w/2, seqHeaderTop, w, seqHeaderH, 8, attrs)
svg.text(point{p.x, seqHeaderTop + 27}, p.name, "middle", ` font-size="14" font-weight="bold"`)
}
// Messages.
for _, it := range d.items {
if it.kind != "msg" {
continue
}
xA := d.participants[d.index[it.from]].x
xB := d.participants[d.index[it.to]].x
y := it.y + 28
text := it.text
if it.num > 0 {
text = fmt.Sprintf("%d: %s", it.num, text)
}
attrs := ` stroke="#333" stroke-width="1.8"`
if strings.HasPrefix(it.arrow, "--") {
attrs += ` stroke-dasharray="7 5"`
}
if xA == xB {
svg.path(fmt.Sprintf("M %d %d H %d V %d H %d", xA+8, y-14, xA+64, y+4, xA+14), attrs)
if strings.HasSuffix(it.arrow, "x") {
svg.line(xA+14, y-4, xA+26, y+12, ` stroke="#333" stroke-width="1.8"`)
svg.line(xA+26, y-4, xA+14, y+12, ` stroke="#333" stroke-width="1.8"`)
} else {
svg.polygon([]point{{xA + 14, y + 4}, {xA + 25, y - 1}, {xA + 25, y + 9}}, ` fill="#333"`)
}
svg.text(point{xA + 74, y - 6}, text, "start", ` font-size="13"`)
continue
}
svg.line(xA, y, xB, y, attrs)
s := 1
if xB < xA {
s = -1
}
tip := xB
if strings.HasSuffix(it.arrow, "x") {
svg.line(tip-5*s, y-6, tip+4*s, y+6, ` stroke="#333" stroke-width="1.8"`)
svg.line(tip+4*s, y-6, tip-5*s, y+6, ` stroke="#333" stroke-width="1.8"`)
} else if strings.HasSuffix(it.arrow, ">") {
svg.polygon([]point{{tip, y}, {tip - 11*s, y - 5}, {tip - 11*s, y + 5}}, ` fill="#333"`)
}
midX := (xA + xB) / 2
svg.text(point{midX, y - 7}, text, "middle", ` font-size="13"`)
}
// Notes.
for _, it := range d.items {
if it.kind != "note" {
continue
}
lines := labelLines(it.text)
w := 0
for _, l := range lines {
w = max(w, textWidth(l, 13))
}
w += 26
h := it.h - 4
var x int
anchor := "start"
switch it.side {
case "left":
x = d.participants[d.index[it.from]].x - 14 - w
if it.over != "" {
x = d.participants[d.index[it.over]].x - 14 - w
}
case "right":
x = d.participants[d.index[it.from]].x + 14
default:
xA := d.participants[d.index[it.from]].x
xB := xA
if it.over != "" {
xB = d.participants[d.index[it.over]].x
}
x = min(xA, xB) - 30
w = abs(xB-xA) + 60
anchor = "middle"
}
svg.rect(x, it.y+2, w, h, 4, ` fill="#fffbe0" stroke="#999"`)
for k, l := range lines {
tx := x + 12
if anchor == "middle" {
tx = x + w/2
}
svg.text(point{tx, it.y + 22 + k*18}, l, anchor, ` font-size="13"`)
}
}
// Dividers.
for _, it := range d.items {
if it.kind != "divider" {
continue
}
y := it.y + 17
svg.line(seqMargin, y, width-seqMargin, y, ` stroke="#999" stroke-dasharray="6 5"`)
w := textWidth(it.text, 13) + 24
svg.rect((width-w)/2, y-11, w, 22, 10, ` fill="#eee" stroke="#999"`)
svg.text(point{width / 2, y + 4}, it.text, "middle", ` font-size="13"`)
}
// Frame outlines and label tabs.
for _, f := range d.frames {
x0 := seqMargin + f.depth*12
fw := width - 2*seqMargin - 2*f.depth*12
top := d.items[f.first].y + 4
low := d.items[f.last].y + d.items[f.last].h
svg.rect(x0, top, fw, low-top, 6, ` fill="none" stroke="#888"`)
if f.label != "" {
tw := min(textWidth(f.label, 13)+16, fw)
svg.rect(x0, top, tw, 22, 0, ` fill="#eee" stroke="#888"`)
svg.text(point{x0 + 8, top + 15}, f.label, "start", ` font-size="13"`)
}
}
return svg.finish()
}