Skip to content

Commit a4f2321

Browse files
Merge pull request #79 from cgreeno/feat/seq-par
2 parents 7d5d793 + 579a633 commit a4f2321

10 files changed

Lines changed: 262 additions & 21 deletions

File tree

README.md

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

561561
## TODOs
562562

@@ -586,7 +586,7 @@ The baseline components for Mermaid work, but there are a lot of things that are
586586
- [x] Notes (`Note over`/`left of`/`right of`)
587587
- [x] `loop` and `opt` blocks
588588
- [x] `alt`/`else` blocks
589-
- [ ] `par`, `critical`, `break`, and `rect` blocks
589+
- [x] `par`, `critical`, `break`, and `rect` blocks
590590

591591
### General
592592

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
sequenceDiagram
2+
par fetch A
3+
A->>B: get
4+
and fetch C
5+
A->>C: get
6+
end
7+
---
8+
+---+ +---+ +---+
9+
| A | | B | | C |
10+
+-+-+ +-+-+ +-+-+
11+
+-[par fetch A]---------+
12+
| | | | |
13+
| | get | | |
14+
| +-------->| | |
15+
+.[fetch C].............+
16+
| | | | |
17+
| | get | | |
18+
| +------------------>| |
19+
| | | | |
20+
+-----------------------+
21+
| | |
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
sequenceDiagram
2+
break invalid
3+
A->>B: abort
4+
end
5+
rect rgb(0,255,0)
6+
A->>B: highlighted
7+
end
8+
---
9+
┌───┐ ┌───┐
10+
│ A │ │ B │
11+
└─┬─┘ └─┬─┘
12+
┌─[break invalid]─┐
13+
│ │ │ │
14+
│ │ abort │ │
15+
│ ├────────►│ │
16+
│ │ │ │
17+
└─────────────────┘
18+
┌─[rect]────────┐
19+
│ │ │ │
20+
│ │ highlighted │
21+
│ ├────────►│ │
22+
│ │ │ │
23+
└───────────────┘
24+
│ │
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
sequenceDiagram
2+
critical connect
3+
A->>B: open
4+
option timeout
5+
A->>B: retry
6+
end
7+
---
8+
┌───┐ ┌───┐
9+
│ A │ │ B │
10+
└─┬─┘ └─┬─┘
11+
┌─[critical connect]─┐
12+
│ │ │ │
13+
│ │ open │ │
14+
│ ├────────►│ │
15+
├┈[timeout]┈┈┈┈┈┈┈┈┈┈┤
16+
│ │ │ │
17+
│ │ retry │ │
18+
│ ├────────►│ │
19+
│ │ │ │
20+
└────────────────────┘
21+
│ │
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
sequenceDiagram
2+
par fetch A
3+
A->>B: get
4+
and fetch C
5+
A->>C: get
6+
end
7+
---
8+
┌───┐ ┌───┐ ┌───┐
9+
│ A │ │ B │ │ C │
10+
└─┬─┘ └─┬─┘ └─┬─┘
11+
┌─[par fetch A]─────────┐
12+
│ │ │ │ │
13+
│ │ get │ │ │
14+
│ ├────────►│ │ │
15+
├┈[fetch C]┈┈┈┈┈┈┈┈┈┈┈┈┈┤
16+
│ │ │ │ │
17+
│ │ get │ │ │
18+
│ ├──────────────────►│ │
19+
│ │ │ │ │
20+
└───────────────────────┘
21+
│ │ │

pkg/sequence/alt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ func TestParseAltErrors(t *testing.T) {
7171
tests := []struct {
7272
name, input, wantErr string
7373
}{
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"},
74+
{"else at top level", "sequenceDiagram\n A->>B: x\n else\n", "outside a matching alt"},
75+
{"else inside loop", "sequenceDiagram\n loop x\n A->>B: y\n else\n end", "outside a matching alt"},
7676
{"unclosed alt", "sequenceDiagram\n alt x\n A->>B: y", "unclosed"},
7777
}
7878
for _, tt := range tests {

pkg/sequence/fragments_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestParseFragmentErrors(t *testing.T) {
145145
{
146146
name: "end without opener",
147147
input: "sequenceDiagram\n A->>B: x\n end",
148-
wantErr: "without matching",
148+
wantErr: "without a matching fragment opener",
149149
},
150150
{
151151
name: "unclosed nested opt",

pkg/sequence/par_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package sequence
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
func firstFragment(sd *SequenceDiagram) *Fragment {
11+
for _, ev := range sd.Events {
12+
if ev.Kind == EventFragmentStart {
13+
return ev.Fragment
14+
}
15+
}
16+
return nil
17+
}
18+
19+
// TestParseParCriticalBreakRect covers the fragment types mermaid tests: par
20+
// (with `and`), critical (with/without `option`), break, and rect.
21+
func TestParseParCriticalBreakRect(t *testing.T) {
22+
tests := []struct {
23+
name string
24+
input string
25+
wantType FragmentType
26+
wantDividers []string
27+
wantLabel string // opener label (after keyword)
28+
}{
29+
{"par with and", "sequenceDiagram\n par a\n A->>B: 1\n and b\n A->>C: 2\n end", FragmentPar, []string{"b"}, "a"},
30+
{"par multiple and", "sequenceDiagram\n par a\n A->>B: 1\n and b\n A->>B: 2\n and c\n A->>B: 3\n end", FragmentPar, []string{"b", "c"}, "a"},
31+
{"critical with option", "sequenceDiagram\n critical conn\n A->>B: 1\n option down\n A->>B: 2\n end", FragmentCritical, []string{"down"}, "conn"},
32+
{"critical without option", "sequenceDiagram\n critical conn\n A->>B: 1\n end", FragmentCritical, nil, "conn"},
33+
{"break", "sequenceDiagram\n break oops\n A->>B: 1\n end", FragmentBreak, nil, "oops"},
34+
{"rect strips rgb", "sequenceDiagram\n rect rgb(0,255,0)\n A->>B: 1\n end", FragmentRect, nil, ""},
35+
{"rect strips rgba", "sequenceDiagram\n rect rgba(0,0,0,0.1)\n A->>B: 1\n end", FragmentRect, nil, ""},
36+
{"rect keeps non-color text", "sequenceDiagram\n rect highlight\n A->>B: 1\n end", FragmentRect, nil, "highlight"},
37+
}
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
sd, err := Parse(tt.input)
41+
if err != nil {
42+
t.Fatalf("parse: %v", err)
43+
}
44+
f := firstFragment(sd)
45+
if f == nil {
46+
t.Fatal("no fragment start")
47+
}
48+
if f.Type != tt.wantType {
49+
t.Errorf("type = %v, want %v", f.Type, tt.wantType)
50+
}
51+
if f.Label != tt.wantLabel {
52+
t.Errorf("label = %q, want %q", f.Label, tt.wantLabel)
53+
}
54+
got := altDividers(sd) // reused: collects EventFragmentDivider labels
55+
if len(got) != len(tt.wantDividers) {
56+
t.Fatalf("dividers = %v, want %v", got, tt.wantDividers)
57+
}
58+
for i, w := range tt.wantDividers {
59+
if got[i] != w {
60+
t.Errorf("divider %d = %q, want %q", i, got[i], w)
61+
}
62+
}
63+
})
64+
}
65+
}
66+
67+
// TestParseDividerMismatch covers dividers used with the wrong fragment type.
68+
func TestParseDividerMismatch(t *testing.T) {
69+
tests := []struct{ name, input, wantErr string }{
70+
{"and outside par", "sequenceDiagram\n A->>B: x\n and\n", "outside a matching par"},
71+
{"option outside critical", "sequenceDiagram\n A->>B: x\n option\n", "outside a matching critical"},
72+
{"and inside alt", "sequenceDiagram\n alt x\n A->>B: y\n and z\n end", "outside a matching par"},
73+
{"option inside par", "sequenceDiagram\n par x\n A->>B: y\n option z\n end", "outside a matching critical"},
74+
{"else inside par", "sequenceDiagram\n par x\n A->>B: y\n else z\n end", "outside a matching alt"},
75+
{"unclosed par", "sequenceDiagram\n par x\n A->>B: y", "unclosed"},
76+
}
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
if _, err := Parse(tt.input); err == nil || !strings.Contains(err.Error(), tt.wantErr) {
80+
t.Errorf("err = %v, want containing %q", err, tt.wantErr)
81+
}
82+
})
83+
}
84+
}
85+
86+
// TestRenderParCriticalBreakRectSmoke renders each in both charsets.
87+
func TestRenderParCriticalBreakRectSmoke(t *testing.T) {
88+
inputs := map[string]string{
89+
"par": "sequenceDiagram\n par a\n A->>B: x\n and b\n A->>B: y\n end",
90+
"critical": "sequenceDiagram\n critical c\n A->>B: x\n option o\n A->>B: y\n end",
91+
"break": "sequenceDiagram\n break oops\n A->>B: x\n end",
92+
"rect": "sequenceDiagram\n rect rgb(0,255,0)\n A->>B: x\n end",
93+
}
94+
for name, in := range inputs {
95+
for _, ascii := range []bool{false, true} {
96+
sd, err := Parse(in)
97+
if err != nil {
98+
t.Fatalf("%s parse: %v", name, err)
99+
}
100+
out, err := Render(sd, diagram.NewTestConfig(ascii, "cli"))
101+
if err != nil {
102+
t.Fatalf("%s render ascii=%v: %v", name, ascii, err)
103+
}
104+
if !strings.Contains(out, "["+name) {
105+
t.Errorf("%s ascii=%v: missing [%s ...] label:\n%s", name, ascii, name, out)
106+
}
107+
}
108+
}
109+
}

pkg/sequence/parser.go

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,16 @@ var (
3131
// fragmentStartRegex matches the opening line of a control-flow fragment,
3232
// e.g. "loop every minute", "opt is premium", "alt is valid". Group 1 is the
3333
// keyword, group 2 is the (optional) label describing the condition.
34-
fragmentStartRegex = regexp.MustCompile(`(?i)^\s*(loop|opt|alt)\b\s*(.*)$`)
34+
fragmentStartRegex = regexp.MustCompile(`(?i)^\s*(loop|opt|alt|par|critical|break|rect)\b\s*(.*)$`)
3535

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(`(?i)^\s*else\b\s*(.*)$`)
36+
// fragmentDividerRegex matches a section divider inside a fragment: "else"
37+
// (alt), "and" (par), or "option" (critical). Group 1 is the keyword, group 2
38+
// the (optional) label for the following section.
39+
fragmentDividerRegex = regexp.MustCompile(`(?i)^\s*(else|and|option)\b\s*(.*)$`)
40+
41+
// rectColorRegex strips a leading rgb()/rgba() colour argument from a rect's
42+
// label (ASCII can't render the fill; PR6 draws a plain frame).
43+
rectColorRegex = regexp.MustCompile(`(?i)^\s*rgba?\([^)]*\)\s*`)
3944

4045
// fragmentEndRegex matches the "end" line that closes a fragment.
4146
fragmentEndRegex = regexp.MustCompile(`(?i)^\s*end\s*$`)
@@ -65,9 +70,13 @@ type SequenceDiagram struct {
6570
type FragmentType int
6671

6772
const (
68-
FragmentLoop FragmentType = iota // loop ... end
69-
FragmentOpt // opt ... end
70-
FragmentAlt // alt ... else ... end
73+
FragmentLoop FragmentType = iota // loop ... end
74+
FragmentOpt // opt ... end
75+
FragmentAlt // alt ... else ... end
76+
FragmentPar // par ... and ... end
77+
FragmentCritical // critical ... option ... end
78+
FragmentBreak // break ... end
79+
FragmentRect // rect ... end
7180
)
7281

7382
func (f FragmentType) String() string {
@@ -78,11 +87,32 @@ func (f FragmentType) String() string {
7887
return "opt"
7988
case FragmentAlt:
8089
return "alt"
90+
case FragmentPar:
91+
return "par"
92+
case FragmentCritical:
93+
return "critical"
94+
case FragmentBreak:
95+
return "break"
96+
case FragmentRect:
97+
return "rect"
8198
default:
8299
return fmt.Sprintf("FragmentType(%d)", int(f))
83100
}
84101
}
85102

103+
// fragmentKeywords maps an opener keyword to its fragment type.
104+
var fragmentKeywords = map[string]FragmentType{
105+
"loop": FragmentLoop, "opt": FragmentOpt, "alt": FragmentAlt,
106+
"par": FragmentPar, "critical": FragmentCritical,
107+
"break": FragmentBreak, "rect": FragmentRect,
108+
}
109+
110+
// dividerKeywords maps a section-divider keyword to the fragment type it must
111+
// appear inside.
112+
var dividerKeywords = map[string]FragmentType{
113+
"else": FragmentAlt, "and": FragmentPar, "option": FragmentCritical,
114+
}
115+
86116
// Fragment describes the opening of a control-flow block: its kind and the
87117
// optional condition text shown in the frame's label tab.
88118
type Fragment struct {
@@ -313,33 +343,41 @@ func Parse(input string) (*SequenceDiagram, error) {
313343
continue
314344
}
315345

316-
// A fragment opener ("loop"/"opt"/"alt") starts a framed block.
346+
// A fragment opener (loop/opt/alt/par/critical/break/rect) starts a block.
317347
if match := fragmentStartRegex.FindStringSubmatch(trimmed); match != nil {
318-
fType := map[string]FragmentType{"loop": FragmentLoop, "opt": FragmentOpt, "alt": FragmentAlt}[strings.ToLower(match[1])]
348+
fType := fragmentKeywords[strings.ToLower(match[1])]
349+
label := strings.TrimSpace(match[2])
350+
// rect's argument is a fill colour we can't render in ASCII; drop it
351+
// so the frame is drawn plain (colour support is a follow-up).
352+
if fType == FragmentRect {
353+
label = strings.TrimSpace(rectColorRegex.ReplaceAllString(label, ""))
354+
}
319355
sd.Events = append(sd.Events, Event{
320356
Kind: EventFragmentStart,
321-
Fragment: &Fragment{Type: fType, Label: strings.TrimSpace(match[2])},
357+
Fragment: &Fragment{Type: fType, Label: label},
322358
})
323359
openFragments = append(openFragments, fType)
324360
continue
325361
}
326362

327-
// "else" divides an alt block into sections.
328-
if match := fragmentElseRegex.FindStringSubmatch(trimmed); match != nil {
329-
if len(openFragments) == 0 || openFragments[len(openFragments)-1] != FragmentAlt {
330-
return nil, fmt.Errorf("line %d: %q outside an alt block", i+2, trimmed)
363+
// A section divider: "else" (alt), "and" (par), "option" (critical). It
364+
// must sit directly inside the matching fragment type.
365+
if match := fragmentDividerRegex.FindStringSubmatch(trimmed); match != nil {
366+
want := dividerKeywords[strings.ToLower(match[1])]
367+
if len(openFragments) == 0 || openFragments[len(openFragments)-1] != want {
368+
return nil, fmt.Errorf("line %d: %q outside a matching %s block", i+2, trimmed, want)
331369
}
332370
sd.Events = append(sd.Events, Event{
333371
Kind: EventFragmentDivider,
334-
Fragment: &Fragment{Type: FragmentAlt, Label: strings.TrimSpace(match[1])},
372+
Fragment: &Fragment{Type: want, Label: strings.TrimSpace(match[2])},
335373
})
336374
continue
337375
}
338376

339377
// "end" closes the most recently opened fragment.
340378
if fragmentEndRegex.MatchString(trimmed) {
341379
if len(openFragments) == 0 {
342-
return nil, fmt.Errorf("line %d: %q without matching loop/opt/alt", i+2, trimmed)
380+
return nil, fmt.Errorf("line %d: %q without a matching fragment opener", i+2, trimmed)
343381
}
344382
sd.Events = append(sd.Events, Event{Kind: EventFragmentEnd})
345383
openFragments = openFragments[:len(openFragments)-1]

pkg/sequence/renderer_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func TestSequenceDiagramRendering(t *testing.T) {
2828
"adjacent_participants_communication.txt",
2929
"alt_basic.txt",
3030
"alt_multiple_else.txt",
31+
"par_basic.txt",
32+
"critical_basic.txt",
33+
"break_rect.txt",
3134
"arrow_types.txt",
3235
"autonumber.txt",
3336
"bidirectional_messages.txt",
@@ -65,6 +68,7 @@ func TestSequenceDiagramRendering_ASCII(t *testing.T) {
6568

6669
goldenFiles := []string{
6770
"alt_basic.txt",
71+
"par_basic.txt",
6872
"note_over_single.txt",
6973
"arrow_types.txt",
7074
"autonumber.txt",
@@ -93,6 +97,9 @@ func TestSequenceDiagramRendering_ASCIISmokeTest(t *testing.T) {
9397
"adjacent_participants_communication.txt",
9498
"alt_basic.txt",
9599
"alt_multiple_else.txt",
100+
"par_basic.txt",
101+
"critical_basic.txt",
102+
"break_rect.txt",
96103
"arrow_types.txt",
97104
"autonumber.txt",
98105
"bidirectional_messages.txt",

0 commit comments

Comments
 (0)