Skip to content

Commit 232e792

Browse files
Merge pull request #76 from cgreeno/feat/seq-note
feat(sequence): render Note annotations
2 parents 3435f50 + 16d2302 commit 232e792

9 files changed

Lines changed: 496 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,8 @@ Note that with `--coords` enabled, the grid-coords shown show the starting locat
553553
- [x] Both ASCII and Unicode rendering modes
554554
- [x] `loop` and `opt` blocks (incl. nesting)
555555
- [x] `autonumber`
556+
- [x] Notes (`Note over A`, `Note over A,B`, `Note left of A`, `Note right of A`)
556557
- [ ] Activation boxes
557-
- [ ] Notes (`Note left of Alice: text`)
558558
- [ ] `alt`/`else`, `par`, `critical`, `break`, `rect` blocks
559559

560560
## TODOs
@@ -582,7 +582,7 @@ The baseline components for Mermaid work, but there are a lot of things that are
582582
### Sequence Diagram Improvements
583583

584584
- [ ] Activation boxes (activate/deactivate)
585-
- [ ] Notes (`Note left of Alice: text`)
585+
- [x] Notes (`Note over`/`left of`/`right of`)
586586
- [x] `loop` and `opt` blocks
587587
- [ ] `alt`, `par`, `critical`, `break`, and `rect` blocks
588588

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
sequenceDiagram
2+
A->>B: request
3+
Note over B: processing
4+
B-->>A: response
5+
---
6+
+---+ +---+
7+
| A | | B |
8+
+-+-+ +-+-+
9+
| |
10+
| request |
11+
+-------->|
12+
| |
13+
| +------------+
14+
| | processing |
15+
| +------------+
16+
| |
17+
| response|
18+
|<........+
19+
| |
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
sequenceDiagram
2+
loop retry
3+
A->>B: try
4+
Note over A,B: keep going
5+
end
6+
---
7+
┌───┐ ┌───┐
8+
│ A │ │ B │
9+
└─┬─┘ └─┬─┘
10+
┌─[loop retry]──┐
11+
│ │ │ │
12+
│ │ try │ │
13+
│ ├────────►│ │
14+
│ │ │ │
15+
│┌────────────┐ │
16+
││ keep going │ │
17+
│└────────────┘ │
18+
│ │ │ │
19+
└───────────────┘
20+
│ │
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
sequenceDiagram
2+
A->>B: request
3+
Note over B: processing
4+
B-->>A: response
5+
---
6+
┌───┐ ┌───┐
7+
│ A │ │ B │
8+
└─┬─┘ └─┬─┘
9+
│ │
10+
│ request │
11+
├────────►│
12+
│ │
13+
│ ┌────────────┐
14+
│ │ processing │
15+
│ └────────────┘
16+
│ │
17+
│ response│
18+
│◄┈┈┈┈┈┈┈┈┤
19+
│ │
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
sequenceDiagram
2+
A->>B: x
3+
B->>C: y
4+
Note over A,C: spans all three
5+
---
6+
┌───┐ ┌───┐ ┌───┐
7+
│ A │ │ B │ │ C │
8+
└─┬─┘ └─┬─┘ └─┬─┘
9+
│ │ │
10+
│ x │ │
11+
├────────►│ │
12+
│ │ │
13+
│ │ y │
14+
│ ├────────►│
15+
│ │ │
16+
┌─────────────────────┐
17+
│ spans all three │
18+
└─────────────────────┘
19+
│ │ │

pkg/sequence/notes_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package sequence
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
// firstNote returns the first note event in the diagram, or nil.
11+
func firstNote(sd *SequenceDiagram) *Note {
12+
for _, ev := range sd.Events {
13+
if ev.Kind == EventNote {
14+
return ev.Note
15+
}
16+
}
17+
return nil
18+
}
19+
20+
// TestParseNotes covers the note variants exercised by mermaid.js's own
21+
// sequence spec: over a single actor, over multiple actors (incl. reversed
22+
// order), left of / right of, the lowercase keyword, special characters, and
23+
// <br/> line breaks.
24+
func TestParseNotes(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
input string
28+
wantPlace NotePlacement
29+
wantActors []string
30+
wantText string
31+
}{
32+
{"over single", "sequenceDiagram\n Note over B: hi", NoteOver, []string{"B"}, "hi"},
33+
{"over multiple", "sequenceDiagram\n Note over A,B: shared", NoteOver, []string{"A", "B"}, "shared"},
34+
{"over reversed", "sequenceDiagram\n Note over B,A: rev", NoteOver, []string{"B", "A"}, "rev"},
35+
{"left of", "sequenceDiagram\n Note left of A: L", NoteLeftOf, []string{"A"}, "L"},
36+
{"right of", "sequenceDiagram\n Note right of A: R", NoteRightOf, []string{"A"}, "R"},
37+
{"lowercase note", "sequenceDiagram\n note over A: low", NoteOver, []string{"A"}, "low"},
38+
// mermaid keeps everything except ';' and '#' in note text; use chars it
39+
// preserves so the expectation is grounded in mermaid's own behavior.
40+
{"special chars", "sequenceDiagram\n Note over A: <>&! 100%", NoteOver, []string{"A"}, "<>&! 100%"},
41+
{"br collapses", "sequenceDiagram\n Note over A: line1<br/>line2", NoteOver, []string{"A"}, "line1<br/>line2"},
42+
{"nowrap prefix stripped", "sequenceDiagram\n Note right of B:nowrap: hi there", NoteRightOf, []string{"B"}, "hi there"},
43+
{"wrap prefix stripped", "sequenceDiagram\n Note over A:wrap: hi", NoteOver, []string{"A"}, "hi"},
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
sd, err := Parse(tt.input)
49+
if err != nil {
50+
t.Fatalf("parse: %v", err)
51+
}
52+
n := firstNote(sd)
53+
if n == nil {
54+
t.Fatal("no note event parsed")
55+
}
56+
if n.Placement != tt.wantPlace {
57+
t.Errorf("placement = %v, want %v", n.Placement, tt.wantPlace)
58+
}
59+
if len(n.Participants) != len(tt.wantActors) {
60+
t.Fatalf("actors = %d, want %d", len(n.Participants), len(tt.wantActors))
61+
}
62+
for i, a := range tt.wantActors {
63+
if n.Participants[i].ID != a {
64+
t.Errorf("actor %d = %q, want %q", i, n.Participants[i].ID, a)
65+
}
66+
}
67+
if n.Text != tt.wantText {
68+
t.Errorf("text = %q, want %q", n.Text, tt.wantText)
69+
}
70+
})
71+
}
72+
}
73+
74+
// TestParseNoteErrors covers malformed notes.
75+
func TestParseNoteErrors(t *testing.T) {
76+
// A note keyword with a placement but no participant is invalid.
77+
if _, err := Parse("sequenceDiagram\n Note over : x"); err == nil {
78+
t.Error("expected error for note with no participant")
79+
}
80+
}
81+
82+
// TestRenderNoteSmoke verifies notes render (with the <br/> collapsed) in both
83+
// charsets without panicking.
84+
func TestRenderNoteSmoke(t *testing.T) {
85+
sd, err := Parse("sequenceDiagram\n A->>B: x\n Note over A,B: a<br/>b\n Note right of B: r")
86+
if err != nil {
87+
t.Fatalf("parse: %v", err)
88+
}
89+
for _, ascii := range []bool{false, true} {
90+
out, err := Render(sd, diagram.NewTestConfig(ascii, "cli"))
91+
if err != nil {
92+
t.Fatalf("render ascii=%v: %v", ascii, err)
93+
}
94+
// <br/> should have been collapsed to a space in the output.
95+
if !strings.Contains(out, "a b") {
96+
t.Errorf("ascii=%v: expected collapsed note text 'a b' in output:\n%s", ascii, out)
97+
}
98+
}
99+
}
100+
101+
// TestNoteLeftOfLeftmostKeepsLifelines guards the fix for a "left of" note on
102+
// the leftmost participant: the box must sit in the reserved left gutter and
103+
// must NOT be clamped on top of the participant lifelines it isn't attached to.
104+
func TestNoteLeftOfLeftmostKeepsLifelines(t *testing.T) {
105+
sd, err := Parse("sequenceDiagram\n A->>B: x\n Note left of A: LEFT")
106+
if err != nil {
107+
t.Fatalf("parse: %v", err)
108+
}
109+
out, err := Render(sd, diagram.NewTestConfig(true, "cli")) // ASCII
110+
if err != nil {
111+
t.Fatalf("render: %v", err)
112+
}
113+
// Both participant boxes must still be intact (not overwritten by the note),
114+
// and the note itself must render.
115+
for _, want := range []string{"| A |", "| B |", "LEFT"} {
116+
if !strings.Contains(out, want) {
117+
t.Errorf("expected %q in output:\n%s", want, out)
118+
}
119+
}
120+
// The note box must open at column 0 (in the gutter), left of the shifted
121+
// participant boxes — i.e. some line starts with the box border, not spaces.
122+
if !strings.Contains(out, "\n+") && !strings.HasPrefix(out, "+") {
123+
t.Errorf("expected the left-of note box to start at column 0:\n%s", out)
124+
}
125+
}
126+
127+
// TestNoteInsideFragmentNotCutByBorder guards F-NEW-1: a note that extends
128+
// beyond its participant span inside a loop/opt frame must be fully contained,
129+
// with the frame widened around it — the frame border must never cut through
130+
// the note text.
131+
func TestNoteInsideFragmentNotCutByBorder(t *testing.T) {
132+
sd, err := Parse("sequenceDiagram\n loop retry\n A->>B: try\n Note left of A: hello world\n end")
133+
if err != nil {
134+
t.Fatalf("parse: %v", err)
135+
}
136+
out, err := Render(sd, diagram.NewTestConfig(true, "cli"))
137+
if err != nil {
138+
t.Fatalf("render: %v", err)
139+
}
140+
// Before the fix the frame's vertical border cut through the box, truncating
141+
// the text; the full note text must appear contiguously.
142+
if !strings.Contains(out, "hello world") {
143+
t.Errorf("note text should be intact inside the frame:\n%s", out)
144+
}
145+
}
146+
147+
// TestNoteInNestedFragmentsStagger guards that a left-protruding note inside
148+
// nested fragments keeps its box intact AND the enclosing frames stay staggered
149+
// (each outer frame sits further left than the one inside it).
150+
func TestNoteInNestedFragmentsStagger(t *testing.T) {
151+
sd, err := Parse("sequenceDiagram\n loop outer\n opt inner\n A->>B: x\n Note left of A: hi\n end\n end")
152+
if err != nil {
153+
t.Fatalf("parse: %v", err)
154+
}
155+
out, err := Render(sd, diagram.NewTestConfig(false, "cli"))
156+
if err != nil {
157+
t.Fatalf("render: %v", err)
158+
}
159+
for _, want := range []string{"hi", "[loop outer]", "[opt inner]"} {
160+
if !strings.Contains(out, want) {
161+
t.Errorf("expected %q intact in output:\n%s", want, out)
162+
}
163+
}
164+
// Frames must not collapse: the opt frame's left border column must be
165+
// strictly greater than the loop frame's (opt sits inside loop).
166+
lines := strings.Split(out, "\n")
167+
col := func(sub string) int {
168+
for _, l := range lines {
169+
if i := strings.Index(l, sub); i >= 0 {
170+
return i
171+
}
172+
}
173+
return -1
174+
}
175+
loopCol, optCol := col("┌─[loop outer]"), col("┌─[opt inner]")
176+
if loopCol < 0 || optCol < 0 || optCol <= loopCol {
177+
t.Errorf("frames should stagger (loop=%d < opt=%d):\n%s", loopCol, optCol, out)
178+
}
179+
}

pkg/sequence/parser.go

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ var (
3535

3636
// fragmentEndRegex matches the "end" line that closes a fragment.
3737
fragmentEndRegex = regexp.MustCompile(`^\s*end\s*$`)
38+
39+
// noteRegex matches note annotations: "Note over A: text", "note left of A:
40+
// text", "Note over A,B: text" (case-insensitive keyword). Group 1 is the
41+
// placement, group 2 the participant list, group 3 the text.
42+
noteRegex = regexp.MustCompile(`^\s*[Nn]ote\s+(right of|left of|over)\s+([^:]+?)\s*:\s*(.*)$`)
3843
)
3944

4045
// SequenceDiagram represents a parsed sequence diagram.
@@ -85,15 +90,34 @@ const (
8590
EventMessage EventKind = iota // a message arrow
8691
EventFragmentStart // the opening line of a loop/opt block
8792
EventFragmentEnd // the matching "end" line
93+
EventNote // a note annotation
8894
)
8995

9096
// Event is one item in the diagram body. Exactly one payload field is set:
91-
// Message when Kind is EventMessage, Fragment when Kind is EventFragmentStart.
92-
// An EventFragmentEnd carries no payload; it just marks where a block closes.
97+
// Message when Kind is EventMessage, Fragment when Kind is EventFragmentStart,
98+
// Note when Kind is EventNote. An EventFragmentEnd carries no payload; it just
99+
// marks where a block closes.
93100
type Event struct {
94101
Kind EventKind
95102
Message *Message
96103
Fragment *Fragment
104+
Note *Note
105+
}
106+
107+
// NotePlacement describes where a note box sits relative to its participant(s).
108+
type NotePlacement int
109+
110+
const (
111+
NoteOver NotePlacement = iota // note over A / note over A,B
112+
NoteLeftOf // note left of A
113+
NoteRightOf // note right of A
114+
)
115+
116+
// Note is an annotation box drawn over or beside participant lifelines.
117+
type Note struct {
118+
Placement NotePlacement
119+
Participants []*Participant // one participant, or two for "over A,B"
120+
Text string
97121
}
98122

99123
type Participant struct {
@@ -198,6 +222,43 @@ func Parse(input string) (*SequenceDiagram, error) {
198222
continue
199223
}
200224

225+
// Notes carry no arrow, so they never collide with messages; a
226+
// placement keyword is required, so a participant named "Note" (e.g.
227+
// "Note->>B: hi") still parses as a message further down.
228+
if m := noteRegex.FindStringSubmatch(trimmed); m != nil {
229+
placement := NoteOver
230+
switch m[1] {
231+
case "left of":
232+
placement = NoteLeftOf
233+
case "right of":
234+
placement = NoteRightOf
235+
}
236+
var parts []*Participant
237+
for _, id := range strings.Split(m[2], ",") {
238+
id = strings.Trim(strings.TrimSpace(id), `"`)
239+
if id != "" {
240+
parts = append(parts, sd.getParticipant(id, participantMap))
241+
}
242+
}
243+
if len(parts) == 0 {
244+
return nil, fmt.Errorf("line %d: note without a participant", i+2)
245+
}
246+
// Mermaid allows an optional wrap:/nowrap: prefix on note text;
247+
// wrapping is irrelevant for single-line ASCII, so just strip it.
248+
text := strings.TrimSpace(m[3])
249+
for _, pre := range []string{"nowrap:", "wrap:"} {
250+
if strings.HasPrefix(text, pre) {
251+
text = strings.TrimSpace(text[len(pre):])
252+
break
253+
}
254+
}
255+
sd.Events = append(sd.Events, Event{
256+
Kind: EventNote,
257+
Note: &Note{Placement: placement, Participants: parts, Text: text},
258+
})
259+
continue
260+
}
261+
201262
if matched, err := sd.parseParticipant(trimmed, participantMap); err != nil {
202263
return nil, fmt.Errorf("line %d: %w", i+2, err)
203264
} else if matched {

0 commit comments

Comments
 (0)