// Copyright (c) 2026 Petr BalvĂ­n (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() }