Skip to content

Commit 3364706

Browse files
cgreenoclaude
andcommitted
feat(sequence): support -> and --> message arrows
Sequence messages could only use ->> (solid) and -->> (dotted). Bare -> and --> (no arrowhead) failed to parse, so any diagram using them rendered nothing at all. - extend messageRegex to accept ->, -->, ->>, -->> - model arrows as (line style, head) via ArrowType.isDotted()/hasHead(), adding SolidOpen (->) and DottedOpen (-->) - render open arrows as a plain line touching the target lifeline, no head - apply the same line-style/head logic to self-messages - exclude '<' from unquoted participant names so an unsupported arrow like the bidirectional "<<->>" fails cleanly instead of being absorbed into a participant name and rendered wrongly Tests: arrow classification, unsupported-arrow rejection (-x, -), <<->>), empty-label open arrow, and golden fixtures covering LTR/RTL and self-messages for every arrow (Unicode + ASCII). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 13c886b commit 3364706

8 files changed

Lines changed: 225 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ Note that with `--coords` enabled, the grid-coords shown show the starting locat
545545

546546
### Sequence Diagrams ✅
547547
- [x] Basic message syntax (`A->>B: message`)
548-
- [x] Solid arrows (`->>`) and dotted arrows (`-->>`)
548+
- [x] Solid and dotted arrows, with or without an arrowhead (`->>`, `-->>`, `->`, `-->`)
549549
- [x] Self-messages (`A->>A: think`)
550550
- [x] Participant declarations (`participant Alice`)
551551
- [x] Participant aliases (`participant A as Alice`)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
sequenceDiagram
2+
A->>B: solid head
3+
A->B: solid open
4+
A-->>B: dotted head
5+
A-->B: dotted open
6+
B->>A: rtl solid head
7+
B->A: rtl solid open
8+
A->>A: self solid head
9+
A-->A: self dotted open
10+
---
11+
+---+ +---+
12+
| A | | B |
13+
+-+-+ +-+-+
14+
| |
15+
| solid head
16+
+-------->|
17+
| |
18+
| solid open
19+
+---------|
20+
| |
21+
| dotted head
22+
+........>|
23+
| |
24+
| dotted open
25+
+.........|
26+
| |
27+
| rtl solid head
28+
|<--------+
29+
| |
30+
| rtl solid open
31+
|---------+
32+
| |
33+
| self solid head
34+
+--+ |
35+
| | |
36+
|<-+ |
37+
| |
38+
| self dotted open
39+
+..+ |
40+
| | |
41+
|..+ |
42+
| |
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
sequenceDiagram
2+
A->>B: solid head
3+
A->B: solid open
4+
A-->>B: dotted head
5+
A-->B: dotted open
6+
B->>A: rtl solid head
7+
B->A: rtl solid open
8+
A->>A: self solid head
9+
A-->A: self dotted open
10+
---
11+
┌───┐ ┌───┐
12+
│ A │ │ B │
13+
└─┬─┘ └─┬─┘
14+
│ │
15+
│ solid head
16+
├────────►│
17+
│ │
18+
│ solid open
19+
├─────────│
20+
│ │
21+
│ dotted head
22+
├┈┈┈┈┈┈┈┈►│
23+
│ │
24+
│ dotted open
25+
├┈┈┈┈┈┈┈┈┈│
26+
│ │
27+
│ rtl solid head
28+
│◄────────┤
29+
│ │
30+
│ rtl solid open
31+
│─────────┤
32+
│ │
33+
│ self solid head
34+
├──┐ │
35+
│ │ │
36+
│◄─┘ │
37+
│ │
38+
│ self dotted open
39+
├┈┈┐ │
40+
│ │ │
41+
│┈┈┘ │
42+
│ │

pkg/sequence/arrows_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package sequence
2+
3+
import "testing"
4+
5+
// TestArrowTypes checks that each message arrow syntax parses to the correct
6+
// ArrowType, including line style (solid/dotted) and whether it has a head.
7+
func TestArrowTypes(t *testing.T) {
8+
tests := []struct {
9+
arrow string
10+
want ArrowType
11+
wantDotted bool
12+
wantHead bool
13+
}{
14+
{"->>", SolidArrow, false, true},
15+
{"-->>", DottedArrow, true, true},
16+
{"->", SolidOpen, false, false},
17+
{"-->", DottedOpen, true, false},
18+
}
19+
20+
for _, tt := range tests {
21+
t.Run(tt.arrow, func(t *testing.T) {
22+
sd, err := Parse("sequenceDiagram\n A" + tt.arrow + "B: msg")
23+
if err != nil {
24+
t.Fatalf("parse %q: %v", tt.arrow, err)
25+
}
26+
if len(sd.Messages) != 1 {
27+
t.Fatalf("expected 1 message, got %d", len(sd.Messages))
28+
}
29+
got := sd.Messages[0].ArrowType
30+
if got != tt.want {
31+
t.Errorf("ArrowType = %v, want %v", got, tt.want)
32+
}
33+
if got.isDotted() != tt.wantDotted {
34+
t.Errorf("isDotted() = %v, want %v", got.isDotted(), tt.wantDotted)
35+
}
36+
if got.hasHead() != tt.wantHead {
37+
t.Errorf("hasHead() = %v, want %v", got.hasHead(), tt.wantHead)
38+
}
39+
})
40+
}
41+
}
42+
43+
// TestUnsupportedArrowsRejected documents that arrow types we don't yet support
44+
// (async -x/-), cross, and bidirectional <<->>) are rejected rather than
45+
// silently mis-parsed. mermaid supports these; adding them is future work.
46+
func TestUnsupportedArrowsRejected(t *testing.T) {
47+
for _, in := range []string{
48+
"sequenceDiagram\n A-xB: cross",
49+
"sequenceDiagram\n A-)B: async",
50+
"sequenceDiagram\n A--xB: dotted cross",
51+
"sequenceDiagram\n A--)B: dotted async",
52+
"sequenceDiagram\n A<<->>B: bidirectional",
53+
} {
54+
if _, err := Parse(in); err == nil {
55+
t.Errorf("expected error for unsupported arrow in %q, got none", in)
56+
}
57+
}
58+
}
59+
60+
// TestOpenArrowEmptyLabel checks an open arrow with an empty label parses, like
61+
// the existing ->> empty-label case.
62+
func TestOpenArrowEmptyLabel(t *testing.T) {
63+
sd, err := Parse("sequenceDiagram\n A->B: ")
64+
if err != nil {
65+
t.Fatalf("parse: %v", err)
66+
}
67+
if len(sd.Messages) != 1 || sd.Messages[0].ArrowType != SolidOpen {
68+
t.Fatalf("expected 1 SolidOpen message, got %d msgs", len(sd.Messages))
69+
}
70+
if sd.Messages[0].Label != "" {
71+
t.Errorf("expected empty label, got %q", sd.Messages[0].Label)
72+
}
73+
}

pkg/sequence/parser.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ var (
1818
// participantRegex matches participant declarations: participant [ID] [as Label]
1919
participantRegex = regexp.MustCompile(`^\s*participant\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(.+))?$`)
2020

21-
// messageRegex matches messages: [From]->>[To]: [Label]
22-
messageRegex = regexp.MustCompile(`^\s*(?:"([^"]+)"|([^\s\->]+))\s*(-->>|->>)\s*(?:"([^"]+)"|([^\s\->]+))\s*:\s*(.*)$`)
21+
// messageRegex matches messages: [From][arrow][To]: [Label]. The arrow is one
22+
// of ->>, -->>, -> or -->. Unquoted participant names exclude the arrow
23+
// characters (- > <) so an unsupported arrow such as the bidirectional
24+
// "<<->>" cannot be silently absorbed into a name — it fails to match and is
25+
// reported as invalid syntax rather than rendered wrongly.
26+
messageRegex = regexp.MustCompile(`^\s*(?:"([^"]+)"|([^\s<>-]+))\s*(-->>|-->|->>|->)\s*(?:"([^"]+)"|([^\s<>-]+))\s*:\s*(.*)$`)
2327

2428
// autonumberRegex matches the autonumber directive
2529
autonumberRegex = regexp.MustCompile(`^\s*autonumber\s*$`)
@@ -109,18 +113,36 @@ type Message struct {
109113
type ArrowType int
110114

111115
const (
112-
SolidArrow ArrowType = iota
113-
DottedArrow
116+
SolidArrow ArrowType = iota // ->> solid line with an arrowhead
117+
DottedArrow // -->> dotted line with an arrowhead
118+
SolidOpen // -> solid line, no arrowhead
119+
DottedOpen // --> dotted line, no arrowhead
114120
)
115121

122+
// isDotted reports whether the arrow is drawn with a dotted (rather than solid)
123+
// line.
124+
func (a ArrowType) isDotted() bool {
125+
return a == DottedArrow || a == DottedOpen
126+
}
127+
128+
// hasHead reports whether the arrow terminates in an arrowhead. The open forms
129+
// (-> and -->) are drawn as a plain line touching the target lifeline.
130+
func (a ArrowType) hasHead() bool {
131+
return a == SolidArrow || a == DottedArrow
132+
}
133+
116134
func (a ArrowType) String() string {
117135
switch a {
118136
case SolidArrow:
119137
return "solid"
120138
case DottedArrow:
121139
return "dotted"
140+
case SolidOpen:
141+
return "solid-open"
142+
case DottedOpen:
143+
return "dotted-open"
122144
default:
123-
return fmt.Sprintf("ArrowType(%d)", a)
145+
return fmt.Sprintf("ArrowType(%d)", int(a))
124146
}
125147
}
126148

@@ -282,9 +304,16 @@ func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Pa
282304
from := sd.getParticipant(fromID, participants)
283305
to := sd.getParticipant(toID, participants)
284306

285-
aType := DottedArrow
286-
if arrow == SolidArrowSyntax {
307+
var aType ArrowType
308+
switch arrow {
309+
case "->>":
287310
aType = SolidArrow
311+
case "-->>":
312+
aType = DottedArrow
313+
case "->":
314+
aType = SolidOpen
315+
case "-->":
316+
aType = DottedOpen
288317
}
289318

290319
msgNumber := 0

pkg/sequence/renderer.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ func renderMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string
395395

396396
line := []rune(buildLifeline(layout, chars))
397397
style := chars.SolidLine
398-
if msg.ArrowType == DottedArrow {
398+
if msg.ArrowType.isDotted() {
399399
style = chars.DottedLine
400400
}
401401

@@ -404,11 +404,19 @@ func renderMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string
404404
for i := from + 1; i < to; i++ {
405405
line[i] = style
406406
}
407-
line[to-1] = chars.ArrowRight
407+
// Open arrows (-> / -->) have no head: draw the line right up to the
408+
// target lifeline instead of an arrowhead.
409+
line[to-1] = style
410+
if msg.ArrowType.hasHead() {
411+
line[to-1] = chars.ArrowRight
412+
}
408413
line[to] = chars.Vertical
409414
} else {
410415
line[to] = chars.Vertical
411-
line[to+1] = chars.ArrowLeft
416+
line[to+1] = style
417+
if msg.ArrowType.hasHead() {
418+
line[to+1] = chars.ArrowLeft
419+
}
412420
for i := to + 2; i < from; i++ {
413421
line[i] = style
414422
}
@@ -463,10 +471,18 @@ func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []st
463471
lines = append(lines, strings.TrimRight(string(line), " "))
464472
}
465473

474+
// Solid arrows keep the solid horizontal glyph; dotted arrows (-->>/-->) use
475+
// the dotted line. For solid arrows style == chars.Horizontal, so output is
476+
// unchanged from before.
477+
style := chars.Horizontal
478+
if msg.ArrowType.isDotted() {
479+
style = chars.DottedLine
480+
}
481+
466482
l1 := ensureWidth(buildLifeline(layout, chars))
467483
l1[center] = chars.TeeRight
468484
for i := 1; i < width; i++ {
469-
l1[center+i] = chars.Horizontal
485+
l1[center+i] = style
470486
}
471487
l1[center+width-1] = chars.SelfTopRight
472488
lines = append(lines, strings.TrimRight(string(l1), " "))
@@ -477,9 +493,13 @@ func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []st
477493

478494
l3 := ensureWidth(buildLifeline(layout, chars))
479495
l3[center] = chars.Vertical
480-
l3[center+1] = chars.ArrowLeft
496+
// Open arrows have no head.
497+
l3[center+1] = style
498+
if msg.ArrowType.hasHead() {
499+
l3[center+1] = chars.ArrowLeft
500+
}
481501
for i := 2; i < width-1; i++ {
482-
l3[center+i] = chars.Horizontal
502+
l3[center+i] = style
483503
}
484504
l3[center+width-1] = chars.SelfBottom
485505
lines = append(lines, strings.TrimRight(string(l3), " "))

pkg/sequence/renderer_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func TestSequenceDiagramRendering(t *testing.T) {
2626
// Test files - stored in sequence/ directory (Unicode expected output)
2727
testFiles := []string{
2828
"adjacent_participants_communication.txt",
29+
"arrow_types.txt",
2930
"autonumber.txt",
3031
"bidirectional_messages.txt",
3132
"dotted_arrows_only.txt",
@@ -58,6 +59,7 @@ func TestSequenceDiagramRendering_ASCII(t *testing.T) {
5859
testDataPath := filepath.Join(getTestDataPath(), "sequence-ascii")
5960

6061
goldenFiles := []string{
62+
"arrow_types.txt",
6163
"autonumber.txt",
6264
"dotted_arrows_only.txt",
6365
"loop_basic.txt",
@@ -82,6 +84,7 @@ func TestSequenceDiagramRendering_ASCIISmokeTest(t *testing.T) {
8284

8385
testFiles := []string{
8486
"adjacent_participants_communication.txt",
87+
"arrow_types.txt",
8588
"autonumber.txt",
8689
"bidirectional_messages.txt",
8790
"dotted_arrows_only.txt",

pkg/sequence/sequence_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ func TestMessageRegex(t *testing.T) {
122122
{"A-->>B: Response", "A", "-->>", "B", "Response", true},
123123
{`"My Service"->>B: Test`, "My Service", "->>", "B", "Test", true},
124124
{"A->>B: ", "A", "->>", "B", "", true},
125-
{"A->B: Test", "", "", "", "", false},
125+
{"A->B: Test", "A", "->", "B", "Test", true},
126+
{"A-->B: Test", "A", "-->", "B", "Test", true},
126127
{"A->>B", "", "", "", "", false},
127128
}
128129

0 commit comments

Comments
 (0)