Skip to content

Commit 1ab2a8f

Browse files
Merge pull request #77 from cgreeno/feat/seq-alt
feat(sequence): render alt/else fragments
2 parents 232e792 + 258b070 commit 1ab2a8f

8 files changed

Lines changed: 339 additions & 36 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,8 @@ Note that with `--coords` enabled, the grid-coords shown show the starting locat
555555
- [x] `autonumber`
556556
- [x] Notes (`Note over A`, `Note over A,B`, `Note left of A`, `Note right of A`)
557557
- [ ] Activation boxes
558-
- [ ] `alt`/`else`, `par`, `critical`, `break`, `rect` blocks
558+
- [x] `alt`/`else` blocks (incl. multiple else, nesting)
559+
- [ ] `par`, `critical`, `break`, `rect` blocks
559560

560561
## TODOs
561562

@@ -584,7 +585,8 @@ The baseline components for Mermaid work, but there are a lot of things that are
584585
- [ ] Activation boxes (activate/deactivate)
585586
- [x] Notes (`Note over`/`left of`/`right of`)
586587
- [x] `loop` and `opt` blocks
587-
- [ ] `alt`, `par`, `critical`, `break`, and `rect` blocks
588+
- [x] `alt`/`else` blocks
589+
- [ ] `par`, `critical`, `break`, and `rect` blocks
588590

589591
### General
590592

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
A->>B: request
3+
alt is valid
4+
B-->>A: success
5+
else is invalid
6+
B-->>A: error
7+
end
8+
---
9+
+---+ +---+
10+
| A | | B |
11+
+-+-+ +-+-+
12+
| |
13+
| request |
14+
+-------->|
15+
+-[alt is valid]-+
16+
| | | |
17+
| | success | |
18+
| |<........+ |
19+
+.[is invalid]...+
20+
| | | |
21+
| | error | |
22+
| |<........+ |
23+
| | | |
24+
+----------------+
25+
| |
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
A->>B: request
3+
alt is valid
4+
B-->>A: success
5+
else is invalid
6+
B-->>A: error
7+
end
8+
---
9+
┌───┐ ┌───┐
10+
│ A │ │ B │
11+
└─┬─┘ └─┬─┘
12+
│ │
13+
│ request │
14+
├────────►│
15+
┌─[alt is valid]─┐
16+
│ │ │ │
17+
│ │ success │ │
18+
│ │◄┈┈┈┈┈┈┈┈┤ │
19+
├┈[is invalid]┈┈┈┤
20+
│ │ │ │
21+
│ │ error │ │
22+
│ │◄┈┈┈┈┈┈┈┈┤ │
23+
│ │ │ │
24+
└────────────────┘
25+
│ │
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
sequenceDiagram
2+
alt a
3+
A->>B: 1
4+
else b
5+
A->>B: 2
6+
else c
7+
A->>B: 3
8+
end
9+
---
10+
┌───┐ ┌───┐
11+
│ A │ │ B │
12+
└─┬─┘ └─┬─┘
13+
┌─[alt a]─────┐
14+
│ │ │ │
15+
│ │ 1 │ │
16+
│ ├────────►│ │
17+
├┈[b]┈┈┈┈┈┈┈┈┈┤
18+
│ │ │ │
19+
│ │ 2 │ │
20+
│ ├────────►│ │
21+
├┈[c]┈┈┈┈┈┈┈┈┈┤
22+
│ │ │ │
23+
│ │ 3 │ │
24+
│ ├────────►│ │
25+
│ │ │ │
26+
└─────────────┘
27+
│ │

pkg/sequence/alt_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package sequence
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
// altDividers returns the else-divider labels in order.
11+
func altDividers(sd *SequenceDiagram) []string {
12+
var out []string
13+
for _, ev := range sd.Events {
14+
if ev.Kind == EventFragmentDivider {
15+
out = append(out, ev.Fragment.Label)
16+
}
17+
}
18+
return out
19+
}
20+
21+
func hasAltStart(sd *SequenceDiagram) bool {
22+
for _, ev := range sd.Events {
23+
if ev.Kind == EventFragmentStart && ev.Fragment.Type == FragmentAlt {
24+
return true
25+
}
26+
}
27+
return false
28+
}
29+
30+
// TestParseAlt covers the alt variants mermaid.js tests: a basic alt/else,
31+
// multiple elses, a no-label alt and else, and special characters.
32+
func TestParseAlt(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
input string
36+
wantMessages int
37+
wantDividers []string
38+
}{
39+
{"alt with else", "sequenceDiagram\n alt valid\n A->>B: ok\n else invalid\n B->>A: err\n end", 2, []string{"invalid"}},
40+
{"multiple elses", "sequenceDiagram\n alt a\n A->>B: 1\n else b\n A->>B: 2\n else c\n A->>B: 3\n end", 3, []string{"b", "c"}},
41+
{"no-label alt and else", "sequenceDiagram\n alt\n A->>B: 1\n else\n A->>B: 2\n end", 2, []string{""}},
42+
{"special chars in label", "sequenceDiagram\n alt <>&! ok\n A->>B: 1\n end", 1, nil},
43+
}
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
sd, err := Parse(tt.input)
47+
if err != nil {
48+
t.Fatalf("parse: %v", err)
49+
}
50+
if !hasAltStart(sd) {
51+
t.Error("expected an alt fragment start")
52+
}
53+
if len(sd.Messages) != tt.wantMessages {
54+
t.Errorf("messages = %d, want %d", len(sd.Messages), tt.wantMessages)
55+
}
56+
got := altDividers(sd)
57+
if len(got) != len(tt.wantDividers) {
58+
t.Fatalf("dividers = %v, want %v", got, tt.wantDividers)
59+
}
60+
for i, w := range tt.wantDividers {
61+
if got[i] != w {
62+
t.Errorf("divider %d = %q, want %q", i, got[i], w)
63+
}
64+
}
65+
})
66+
}
67+
}
68+
69+
// TestParseAltErrors covers malformed alt/else usage.
70+
func TestParseAltErrors(t *testing.T) {
71+
tests := []struct {
72+
name, input, wantErr string
73+
}{
74+
{"else at top level", "sequenceDiagram\n A->>B: x\n else\n", "outside an alt"},
75+
{"else inside loop", "sequenceDiagram\n loop x\n A->>B: y\n else\n end", "outside an alt"},
76+
{"unclosed alt", "sequenceDiagram\n alt x\n A->>B: y", "unclosed"},
77+
}
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
_, err := Parse(tt.input)
81+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
82+
t.Errorf("err = %v, want containing %q", err, tt.wantErr)
83+
}
84+
})
85+
}
86+
}
87+
88+
// TestRenderAltSmoke renders an alt (incl. nested) in both charsets and checks
89+
// the frame + divider labels appear without panicking.
90+
func TestRenderAltSmoke(t *testing.T) {
91+
input := "sequenceDiagram\n alt outer\n A->>B: x\n alt inner\n A->>B: y\n else inner2\n A->>B: z\n end\n else outer2\n A->>B: w\n end"
92+
sd, err := Parse(input)
93+
if err != nil {
94+
t.Fatalf("parse: %v", err)
95+
}
96+
for _, ascii := range []bool{false, true} {
97+
out, err := Render(sd, diagram.NewTestConfig(ascii, "cli"))
98+
if err != nil {
99+
t.Fatalf("render ascii=%v: %v", ascii, err)
100+
}
101+
for _, want := range []string{"[alt outer]", "[outer2]", "[alt inner]", "[inner2]"} {
102+
if !strings.Contains(out, want) {
103+
t.Errorf("ascii=%v: missing %q\n%s", ascii, want, out)
104+
}
105+
}
106+
}
107+
}

pkg/sequence/parser.go

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ var (
2929
autonumberRegex = regexp.MustCompile(`^\s*autonumber\s*$`)
3030

3131
// fragmentStartRegex matches the opening line of a control-flow fragment,
32-
// e.g. "loop every minute" or "opt is premium". Group 1 is the keyword,
33-
// group 2 is the (optional) label describing the condition.
34-
fragmentStartRegex = regexp.MustCompile(`^\s*(loop|opt)\b\s*(.*)$`)
32+
// e.g. "loop every minute", "opt is premium", "alt is valid". Group 1 is the
33+
// keyword, group 2 is the (optional) label describing the condition.
34+
fragmentStartRegex = regexp.MustCompile(`^\s*(loop|opt|alt)\b\s*(.*)$`)
35+
36+
// fragmentElseRegex matches an "else" divider inside an alt block. Group 1 is
37+
// the (optional) condition label for the following section.
38+
fragmentElseRegex = regexp.MustCompile(`^\s*else\b\s*(.*)$`)
3539

3640
// fragmentEndRegex matches the "end" line that closes a fragment.
3741
fragmentEndRegex = regexp.MustCompile(`^\s*end\s*$`)
@@ -63,6 +67,7 @@ type FragmentType int
6367
const (
6468
FragmentLoop FragmentType = iota // loop ... end
6569
FragmentOpt // opt ... end
70+
FragmentAlt // alt ... else ... end
6671
)
6772

6873
func (f FragmentType) String() string {
@@ -71,6 +76,8 @@ func (f FragmentType) String() string {
7176
return "loop"
7277
case FragmentOpt:
7378
return "opt"
79+
case FragmentAlt:
80+
return "alt"
7481
default:
7582
return fmt.Sprintf("FragmentType(%d)", int(f))
7683
}
@@ -87,12 +94,30 @@ type Fragment struct {
8794
type EventKind int
8895

8996
const (
90-
EventMessage EventKind = iota // a message arrow
91-
EventFragmentStart // the opening line of a loop/opt block
92-
EventFragmentEnd // the matching "end" line
93-
EventNote // a note annotation
97+
EventMessage EventKind = iota // a message arrow
98+
EventFragmentStart // the opening line of a loop/opt/alt block
99+
EventFragmentDivider // an "else" section divider within an alt
100+
EventFragmentEnd // the matching "end" line
101+
EventNote // a note annotation
94102
)
95103

104+
func (k EventKind) String() string {
105+
switch k {
106+
case EventMessage:
107+
return "message"
108+
case EventFragmentStart:
109+
return "fragment-start"
110+
case EventFragmentDivider:
111+
return "fragment-divider"
112+
case EventFragmentEnd:
113+
return "fragment-end"
114+
case EventNote:
115+
return "note"
116+
default:
117+
return fmt.Sprintf("EventKind(%d)", int(k))
118+
}
119+
}
120+
96121
// Event is one item in the diagram body. Exactly one payload field is set:
97122
// Message when Kind is EventMessage, Fragment when Kind is EventFragmentStart,
98123
// Note when Kind is EventNote. An EventFragmentEnd carries no payload; it just
@@ -205,10 +230,10 @@ func Parse(input string) (*SequenceDiagram, error) {
205230
Autonumber: false,
206231
}
207232
participantMap := make(map[string]*Participant)
208-
// openFragments counts how many loop/opt blocks are currently open so we can
209-
// reject an "end" with no matching opener and, at the very end, an opener
210-
// with no matching "end".
211-
openFragments := 0
233+
// openFragments is a stack of the fragment types currently open, so we can
234+
// reject an "end"/"else" with no matching opener, validate that "else" only
235+
// appears inside an "alt", and detect an opener with no matching "end".
236+
var openFragments []FragmentType
212237

213238
for i, line := range lines {
214239
trimmed := strings.TrimSpace(line)
@@ -274,35 +299,44 @@ func Parse(input string) (*SequenceDiagram, error) {
274299
continue
275300
}
276301

277-
// A fragment opener ("loop"/"opt") starts a framed block.
302+
// A fragment opener ("loop"/"opt"/"alt") starts a framed block.
278303
if match := fragmentStartRegex.FindStringSubmatch(trimmed); match != nil {
279-
fType := FragmentLoop
280-
if match[1] == "opt" {
281-
fType = FragmentOpt
282-
}
304+
fType := map[string]FragmentType{"loop": FragmentLoop, "opt": FragmentOpt, "alt": FragmentAlt}[match[1]]
283305
sd.Events = append(sd.Events, Event{
284306
Kind: EventFragmentStart,
285307
Fragment: &Fragment{Type: fType, Label: strings.TrimSpace(match[2])},
286308
})
287-
openFragments++
309+
openFragments = append(openFragments, fType)
310+
continue
311+
}
312+
313+
// "else" divides an alt block into sections.
314+
if match := fragmentElseRegex.FindStringSubmatch(trimmed); match != nil {
315+
if len(openFragments) == 0 || openFragments[len(openFragments)-1] != FragmentAlt {
316+
return nil, fmt.Errorf("line %d: %q outside an alt block", i+2, trimmed)
317+
}
318+
sd.Events = append(sd.Events, Event{
319+
Kind: EventFragmentDivider,
320+
Fragment: &Fragment{Type: FragmentAlt, Label: strings.TrimSpace(match[1])},
321+
})
288322
continue
289323
}
290324

291325
// "end" closes the most recently opened fragment.
292326
if fragmentEndRegex.MatchString(trimmed) {
293-
if openFragments == 0 {
294-
return nil, fmt.Errorf("line %d: %q without matching loop/opt", i+2, trimmed)
327+
if len(openFragments) == 0 {
328+
return nil, fmt.Errorf("line %d: %q without matching loop/opt/alt", i+2, trimmed)
295329
}
296330
sd.Events = append(sd.Events, Event{Kind: EventFragmentEnd})
297-
openFragments--
331+
openFragments = openFragments[:len(openFragments)-1]
298332
continue
299333
}
300334

301335
return nil, fmt.Errorf("line %d: invalid syntax: %q", i+2, trimmed)
302336
}
303337

304-
if openFragments > 0 {
305-
return nil, fmt.Errorf("unclosed loop/opt fragment: missing %d \"end\"", openFragments)
338+
if len(openFragments) > 0 {
339+
return nil, fmt.Errorf("unclosed fragment: missing %d \"end\"", len(openFragments))
306340
}
307341

308342
if len(sd.Participants) == 0 {

0 commit comments

Comments
 (0)