Skip to content

Commit e98cc25

Browse files
cgreenoclaude
andcommitted
feat(sequence): central connections (mermaid 11.16)
Renders mermaid's central-connection markers — "()" on either side of any message arrow (A ()->>() B) — as a circle where the message meets that lifeline: o────►o. Composes with all ten arrow types and self-messages. Grammar grounded in sequenceDiagram.jison (the "()" lexer token + three signal rules: forward, reverse, dual). Unquoted participant names now exclude "(" so the marker binds to the arrow rather than the name — mermaid's ACTOR token excludes parentheses too, so this is parity-positive. Golden files hand-authored first as the output spec; malformed near-miss forms stay loud errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aaba515 commit e98cc25

11 files changed

Lines changed: 201 additions & 51 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ Note that with `--coords` enabled, the grid-coords shown show the starting locat
596596
- [x] Basic message syntax (`A->>B: message`)
597597
- [x] Solid and dotted arrows, with or without an arrowhead (`->>`, `-->>`, `->`, `-->`)
598598
- [x] Cross (`-x`, `--x`), async point (`-)`, `--)`) and bidirectional (`<<->>`, `<<-->>`) arrows
599+
- [x] Central connections (`A ()->>() B`) on any arrow type
599600
- [x] Self-messages (`A->>A: think`)
600601
- [x] Participant declarations (`participant Alice`)
601602
- [x] Participant aliases (`participant A as Alice`)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
sequenceDiagram
2+
participant A
3+
A ()->>() A: t1
4+
---
5+
+---+
6+
| A |
7+
+-+-+
8+
|
9+
| t1
10+
o--+
11+
| |
12+
o<-+
13+
|
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
sequenceDiagram
2+
participant A
3+
participant B
4+
A ->>() B: t1
5+
A ()->> B: t2
6+
A ()->>() B: t3
7+
B ()--x() A: t4
8+
A ()<<->>() B: t5
9+
---
10+
+---+ +---+
11+
| A | | B |
12+
+-+-+ +-+-+
13+
| |
14+
| t1 |
15+
+-------->o
16+
| |
17+
| t2 |
18+
o-------->|
19+
| |
20+
| t3 |
21+
o-------->o
22+
| |
23+
| t4 |
24+
ox........o
25+
| |
26+
| t5 |
27+
o<------->o
28+
| |
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
sequenceDiagram
2+
participant A
3+
A ()->>() A: t1
4+
---
5+
┌───┐
6+
│ A │
7+
└─┬─┘
8+
9+
│ t1
10+
o──┐
11+
│ │
12+
o◄─┘
13+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
sequenceDiagram
2+
participant A
3+
participant B
4+
A ->>() B: t1
5+
A ()->> B: t2
6+
A ()->>() B: t3
7+
B ()--x() A: t4
8+
A ()<<->>() B: t5
9+
---
10+
┌───┐ ┌───┐
11+
│ A │ │ B │
12+
└─┬─┘ └─┬─┘
13+
│ │
14+
│ t1 │
15+
├────────►o
16+
│ │
17+
│ t2 │
18+
o────────►│
19+
│ │
20+
│ t3 │
21+
o────────►o
22+
│ │
23+
│ t4 │
24+
o×┈┈┈┈┈┈┈┈o
25+
│ │
26+
│ t5 │
27+
o◄───────►o
28+
│ │

pkg/sequence/arrows_test.go

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,50 @@ func TestArrowSpecParityForms(t *testing.T) {
7171
}
7272
}
7373

74-
// TestCentralConnectionsRejected documents that mermaid's central-connection
75-
// syntax (circle markers at the lifeline: "A ()->>() B", released in mermaid
76-
// 11.16) is rejected loudly rather than silently mis-parsed. It composes with
77-
// every arrow type, so supporting it is its own follow-up.
78-
func TestCentralConnectionsRejected(t *testing.T) {
74+
// TestCentralConnections checks mermaid's central-connection syntax (circle
75+
// markers at the lifeline, mermaid 11.16): "()" on either side of any arrow.
76+
// The "()" is its own token in mermaid's lexer, so spaces around it are legal.
77+
func TestCentralConnections(t *testing.T) {
78+
tests := []struct {
79+
in string
80+
arrow ArrowType
81+
from, to bool
82+
}{
83+
{"sequenceDiagram\n Alice ()->>() Bob: dual", SolidArrow, true, true},
84+
{"sequenceDiagram\n Alice ()-x() Bob: cross dual", SolidCross, true, true},
85+
{"sequenceDiagram\n Alice ()<<->>() Bob: bidirectional dual", BidirectionalSolid, true, true},
86+
{"sequenceDiagram\n Alice ->>() Bob: forward", SolidArrow, false, true},
87+
{"sequenceDiagram\n Alice ()->> Bob: reverse", SolidArrow, true, false},
88+
{"sequenceDiagram\n Alice () -->> () Bob: spaced dual", DottedArrow, true, true},
89+
{"sequenceDiagram\n Alice ()--)() Bob: point dual", DottedPoint, true, true},
90+
}
91+
for _, tt := range tests {
92+
sd, err := Parse(tt.in)
93+
if err != nil {
94+
t.Errorf("Parse(%q): %v", tt.in, err)
95+
continue
96+
}
97+
if len(sd.Messages) != 1 {
98+
t.Errorf("Parse(%q): expected 1 message, got %d", tt.in, len(sd.Messages))
99+
continue
100+
}
101+
m := sd.Messages[0]
102+
if m.ArrowType != tt.arrow || m.CentralFrom != tt.from || m.CentralTo != tt.to {
103+
t.Errorf("Parse(%q): got arrow=%v centralFrom=%v centralTo=%v, want %v/%v/%v",
104+
tt.in, m.ArrowType, m.CentralFrom, m.CentralTo, tt.arrow, tt.from, tt.to)
105+
}
106+
}
107+
}
108+
109+
// TestMalformedCentralConnectionsRejected keeps near-miss circle forms loud.
110+
func TestMalformedCentralConnectionsRejected(t *testing.T) {
79111
for _, in := range []string{
80-
"sequenceDiagram\n Alice ()->>() Bob: dual",
81-
"sequenceDiagram\n Alice ()-x() Bob: cross dual",
82-
"sequenceDiagram\n Alice ()<<->>() Bob: bidirectional dual",
83-
"sequenceDiagram\n Alice ->>() Bob: forward",
84-
"sequenceDiagram\n Alice ()->> Bob: reverse",
112+
"sequenceDiagram\n Alice (->>) Bob: parens around arrow",
113+
"sequenceDiagram\n Alice ( )->> Bob: space inside circle",
114+
"sequenceDiagram\n Alice (()->> Bob: unbalanced",
85115
} {
86116
if _, err := Parse(in); err == nil {
87-
t.Errorf("expected error for central-connection syntax %q, got none", in)
117+
t.Errorf("expected error for malformed central connection %q, got none", in)
88118
}
89119
}
90120
}

pkg/sequence/charset.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type BoxChars struct {
1717
CrossHead rune // head of -x / --x (lost/failed message)
1818
PointRight rune // head of -) / --) (async message)
1919
PointLeft rune
20+
Circle rune // central-connection marker ("()") on a lifeline
2021
SolidLine rune
2122
DottedLine rune
2223
SelfTopRight rune
@@ -39,28 +40,32 @@ var ASCII = BoxChars{
3940
CrossHead: 'x',
4041
PointRight: ')',
4142
PointLeft: '(',
43+
Circle: 'o',
4244
SolidLine: '-',
4345
DottedLine: '.',
4446
SelfTopRight: '+',
4547
SelfBottom: '+',
4648
}
4749

4850
var Unicode = BoxChars{
49-
TopLeft: '┌',
50-
TopRight: '┐',
51-
BottomLeft: '└',
52-
BottomRight: '┘',
53-
Horizontal: '─',
54-
Vertical: '│',
55-
TeeDown: '┬',
56-
TeeRight: '├',
57-
TeeLeft: '┤',
58-
Cross: '┼',
59-
ArrowRight: '►',
60-
ArrowLeft: '◄',
61-
CrossHead: '×',
62-
PointRight: ')',
63-
PointLeft: '(',
51+
TopLeft: '┌',
52+
TopRight: '┐',
53+
BottomLeft: '└',
54+
BottomRight: '┘',
55+
Horizontal: '─',
56+
Vertical: '│',
57+
TeeDown: '┬',
58+
TeeRight: '├',
59+
TeeLeft: '┤',
60+
Cross: '┼',
61+
ArrowRight: '►',
62+
ArrowLeft: '◄',
63+
CrossHead: '×',
64+
PointRight: ')',
65+
PointLeft: '(',
66+
// 'o' rather than '○': the latter is East-Asian-ambiguous width and
67+
// would break column alignment in CJK-capable terminals.
68+
Circle: 'o',
6469
SolidLine: '─',
6570
DottedLine: '┈',
6671
SelfTopRight: '┐',

pkg/sequence/parser.go

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

21-
// messageRegex matches messages: [From][arrow][To]: [Label]. The arrow is
22-
// one of mermaid's ten message types: ->> / -->> (arrowhead), -> / -->
23-
// (open), -x / --x (cross), -) / --) (async point), <<->> / <<-->>
24-
// (bidirectional). Longer alternatives come first so e.g. "-->>" is never
25-
// consumed as "-->". Unquoted participant names exclude the arrow
26-
// characters (- > <) so a malformed arrow cannot be silently absorbed into
27-
// a name — it fails to match and is reported as invalid syntax rather than
28-
// rendered wrongly.
29-
messageRegex = regexp.MustCompile(`^\s*(?:"([^"]+)"|([^\s<>-]+))\s*(<<-->>|<<->>|-->>|--[x)]|-->|->>|-[x)]|->)\s*(?:"([^"]+)"|([^\s<>-]+))\s*:\s*(.*)$`)
21+
// messageRegex matches messages: [From][()][arrow][()][To]: [Label]. The
22+
// arrow is one of mermaid's ten message types: ->> / -->> (arrowhead),
23+
// -> / --> (open), -x / --x (cross), -) / --) (async point), <<->> /
24+
// <<-->> (bidirectional). Longer alternatives come first so e.g. "-->>"
25+
// is never consumed as "-->". An optional "()" on either side of the
26+
// arrow marks a central connection (mermaid 11.16): a circle where the
27+
// message meets that lifeline. Unquoted participant names exclude the
28+
// arrow characters (- > <) so a malformed arrow cannot be silently
29+
// absorbed into a name — it fails to match and is reported as invalid
30+
// syntax rather than rendered wrongly — and "(" so a central-connection
31+
// marker binds to the arrow, not the name (mermaid's ACTOR token excludes
32+
// parentheses too).
33+
messageRegex = regexp.MustCompile(`^\s*(?:"([^"]+)"|([^\s<>(-]+))\s*(\(\))?\s*(<<-->>|<<->>|-->>|--[x)]|-->|->>|-[x)]|->)\s*(\(\))?\s*(?:"([^"]+)"|([^\s<>(-]+))\s*:\s*(.*)$`)
3034

3135
// autonumberRegex matches the autonumber directive
3236
autonumberRegex = regexp.MustCompile(`(?i)^\s*autonumber\s*$`)
@@ -189,7 +193,12 @@ type Message struct {
189193
To *Participant
190194
Label string
191195
ArrowType ArrowType
192-
Number int // Message number when autonumber is enabled (0 means no number)
196+
// CentralFrom / CentralTo mark central connections (mermaid's "()" on
197+
// either side of the arrow): a circle drawn where the message meets that
198+
// participant's lifeline.
199+
CentralFrom bool
200+
CentralTo bool
201+
Number int // Message number when autonumber is enabled (0 means no number)
193202
}
194203

195204
type ArrowType int
@@ -485,14 +494,16 @@ func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Pa
485494
fromID = match[1]
486495
}
487496

488-
arrow := match[3]
497+
centralFrom := match[3] != ""
498+
arrow := match[4]
499+
centralTo := match[5] != ""
489500

490-
toID := match[5]
491-
if match[4] != "" {
492-
toID = match[4]
501+
toID := match[7]
502+
if match[6] != "" {
503+
toID = match[6]
493504
}
494505

495-
label := strings.TrimSpace(match[6])
506+
label := strings.TrimSpace(match[8])
496507

497508
from := sd.getParticipant(fromID, participants)
498509
to := sd.getParticipant(toID, participants)
@@ -527,11 +538,13 @@ func (sd *SequenceDiagram) parseMessage(line string, participants map[string]*Pa
527538
}
528539

529540
msg := &Message{
530-
From: from,
531-
To: to,
532-
Label: label,
533-
ArrowType: aType,
534-
Number: msgNumber,
541+
From: from,
542+
To: to,
543+
Label: label,
544+
ArrowType: aType,
545+
CentralFrom: centralFrom,
546+
CentralTo: centralTo,
547+
Number: msgNumber,
535548
}
536549
sd.Messages = append(sd.Messages, msg)
537550
sd.Events = append(sd.Events, Event{Kind: EventMessage, Message: msg})

pkg/sequence/renderer.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,13 @@ func renderMessage(msg *Message, layout *diagramLayout, chars BoxChars) []string
671671
}
672672
line[from] = chars.TeeLeft
673673
}
674+
// Central connections replace the lifeline attachment with a circle.
675+
if msg.CentralFrom {
676+
line[from] = chars.Circle
677+
}
678+
if msg.CentralTo {
679+
line[to] = chars.Circle
680+
}
674681
lines = append(lines, strings.TrimRight(string(line), " "))
675682
return lines
676683
}
@@ -730,6 +737,9 @@ func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []st
730737

731738
l1 := ensureWidth(buildLifeline(layout, chars))
732739
l1[center] = chars.TeeRight
740+
if msg.CentralFrom {
741+
l1[center] = chars.Circle
742+
}
733743
for i := 1; i < width; i++ {
734744
l1[center+i] = style
735745
}
@@ -742,6 +752,9 @@ func renderSelfMessage(msg *Message, layout *diagramLayout, chars BoxChars) []st
742752

743753
l3 := ensureWidth(buildLifeline(layout, chars))
744754
l3[center] = chars.Vertical
755+
if msg.CentralTo {
756+
l3[center] = chars.Circle
757+
}
745758
// Open arrows have no head. A bidirectional self-message collapses to a
746759
// single head: both of its ends sit on the same lifeline, and the return
747760
// head is where they coincide.

pkg/sequence/renderer_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ func TestSequenceDiagramRendering(t *testing.T) {
3232
"critical_basic.txt",
3333
"break_rect.txt",
3434
"arrow_types.txt",
35+
"central_connections.txt",
36+
"central_connection_self.txt",
3537
"cross_arrows.txt",
3638
"async_point_arrows.txt",
3739
"bidirectional_arrows.txt",
@@ -75,6 +77,8 @@ func TestSequenceDiagramRendering_ASCII(t *testing.T) {
7577
"par_basic.txt",
7678
"note_over_single.txt",
7779
"arrow_types.txt",
80+
"central_connections.txt",
81+
"central_connection_self.txt",
7882
"cross_arrows.txt",
7983
"async_point_arrows.txt",
8084
"bidirectional_arrows.txt",
@@ -109,6 +113,8 @@ func TestSequenceDiagramRendering_ASCIISmokeTest(t *testing.T) {
109113
"critical_basic.txt",
110114
"break_rect.txt",
111115
"arrow_types.txt",
116+
"central_connections.txt",
117+
"central_connection_self.txt",
112118
"cross_arrows.txt",
113119
"async_point_arrows.txt",
114120
"bidirectional_arrows.txt",

0 commit comments

Comments
 (0)