Skip to content

Commit 7d5d793

Browse files
Merge pull request #78 from cgreeno/feat/seq-caseinsensitive
2 parents 1ab2a8f + 2f4823d commit 7d5d793

4 files changed

Lines changed: 143 additions & 11 deletions

File tree

cmd/parse_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ func TestGraphTypeDetection(t *testing.T) {
201201
{"CRLF line ending", "graph TD\r\nA --> B", "TD", false},
202202
{"tab separator", "graph\tLR\nA --> B", "LR", false},
203203
{"lowercase direction errors", "graph td\nA --> B", "", true},
204+
{"uppercase graph keyword errors", "GRAPH TD\nA --> B", "", true}, // flowchart stays case-sensitive (mermaid parity)
204205
{"extra tokens error", "graph TD foo\nA --> B", "", true},
205206
{"unknown type errors", "sequenceDiagram\nA->>B: x", "", true},
206207
{"unknown direction errors", "graph SIDEWAYS\nA --> B", "", true},

pkg/sequence/case_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package sequence
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
// TestSequenceKeywordsCaseInsensitive verifies that every sequence keyword
9+
// parses in any case. mermaid's sequence grammar declares %options
10+
// case-insensitive, so upper/mixed-case keywords must behave identically to
11+
// lowercase. We assert not just that it parses, but that the SEMANTICS are
12+
// right (the keyword is classified correctly), since the risk is a captured
13+
// keyword being switched on with the wrong case.
14+
func TestSequenceKeywordsCaseInsensitive(t *testing.T) {
15+
t.Run("diagram keyword", func(t *testing.T) {
16+
if _, err := Parse("SEQUENCEDIAGRAM\n A->>B: x"); err != nil {
17+
t.Errorf("uppercase sequenceDiagram should parse: %v", err)
18+
}
19+
})
20+
21+
t.Run("participant and autonumber", func(t *testing.T) {
22+
sd, err := Parse("sequenceDiagram\n PARTICIPANT A as Alice\n AUTONUMBER\n A->>B: x")
23+
if err != nil {
24+
t.Fatalf("parse: %v", err)
25+
}
26+
if !sd.Autonumber {
27+
t.Error("AUTONUMBER should enable autonumber")
28+
}
29+
if sd.Participants[0].Label != "Alice" {
30+
t.Errorf("participant label = %q, want Alice", sd.Participants[0].Label)
31+
}
32+
})
33+
34+
t.Run("loop/end", func(t *testing.T) {
35+
mustFragment(t, "sequenceDiagram\n LOOP r\n A->>B: x\n END", FragmentLoop)
36+
})
37+
t.Run("opt/end", func(t *testing.T) {
38+
mustFragment(t, "sequenceDiagram\n OPT r\n A->>B: x\n END", FragmentOpt)
39+
})
40+
41+
t.Run("alt/else/end", func(t *testing.T) {
42+
sd := mustFragment(t, "sequenceDiagram\n ALT a\n A->>B: x\n ELSE b\n A->>B: y\n END", FragmentAlt)
43+
if got := altDividers(sd); len(got) != 1 || got[0] != "b" {
44+
t.Errorf("ELSE divider = %v, want [b]", got)
45+
}
46+
})
47+
48+
t.Run("note placements", func(t *testing.T) {
49+
mustNote(t, "sequenceDiagram\n NOTE OVER A: hi", NoteOver)
50+
mustNote(t, "sequenceDiagram\n Note Left Of A: hi", NoteLeftOf)
51+
mustNote(t, "sequenceDiagram\n note RIGHT OF A: hi", NoteRightOf)
52+
})
53+
54+
t.Run("wrap prefix any case", func(t *testing.T) {
55+
n := mustNote(t, "sequenceDiagram\n Note over A:NOWRAP: hello", NoteOver)
56+
if n.Text != "hello" {
57+
t.Errorf("NOWRAP: prefix should be stripped, got %q", n.Text)
58+
}
59+
})
60+
61+
t.Run("lower and upper produce identical structure", func(t *testing.T) {
62+
body := "%s\n participant A\n A->>B: x\n %s ok\n B-->>A: y\n %s no\n B-->>A: z\n %s"
63+
lower := fmt.Sprintf(body, "sequenceDiagram", "alt", "else", "end")
64+
upper := fmt.Sprintf(body, "SEQUENCEDIAGRAM", "ALT", "ELSE", "END")
65+
lo, err1 := Parse(lower)
66+
up, err2 := Parse(upper)
67+
if err1 != nil || err2 != nil {
68+
t.Fatalf("parse errors: lower=%v upper=%v", err1, err2)
69+
}
70+
if len(lo.Events) != len(up.Events) || len(lo.Messages) != len(up.Messages) {
71+
t.Fatalf("structure differs: lower %d events/%d msgs, upper %d/%d",
72+
len(lo.Events), len(lo.Messages), len(up.Events), len(up.Messages))
73+
}
74+
for i := range lo.Events {
75+
if lo.Events[i].Kind != up.Events[i].Kind {
76+
t.Errorf("event %d kind differs: %v vs %v", i, lo.Events[i].Kind, up.Events[i].Kind)
77+
}
78+
}
79+
})
80+
}
81+
82+
func mustFragment(t *testing.T, input string, want FragmentType) *SequenceDiagram {
83+
t.Helper()
84+
sd, err := Parse(input)
85+
if err != nil {
86+
t.Fatalf("parse %q: %v", input, err)
87+
}
88+
for _, ev := range sd.Events {
89+
if ev.Kind == EventFragmentStart {
90+
if ev.Fragment.Type != want {
91+
t.Errorf("fragment type = %v, want %v", ev.Fragment.Type, want)
92+
}
93+
return sd
94+
}
95+
}
96+
t.Fatalf("no fragment start parsed from %q", input)
97+
return nil
98+
}
99+
100+
func mustNote(t *testing.T, input string, want NotePlacement) *Note {
101+
t.Helper()
102+
sd, err := Parse(input)
103+
if err != nil {
104+
t.Fatalf("parse %q: %v", input, err)
105+
}
106+
n := firstNote(sd)
107+
if n == nil {
108+
t.Fatalf("no note parsed from %q", input)
109+
}
110+
if n.Placement != want {
111+
t.Errorf("placement = %v, want %v", n.Placement, want)
112+
}
113+
return n
114+
}

pkg/sequence/parser.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const (
1616

1717
var (
1818
// participantRegex matches participant declarations: participant [ID] [as Label]
19-
participantRegex = regexp.MustCompile(`^\s*participant\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(.+))?$`)
19+
participantRegex = regexp.MustCompile(`(?i)^\s*participant\s+(?:"([^"]+)"|(\S+))(?:\s+as\s+(.+))?$`)
2020

2121
// messageRegex matches messages: [From][arrow][To]: [Label]. The arrow is one
2222
// of ->>, -->>, -> or -->. Unquoted participant names exclude the arrow
@@ -26,24 +26,24 @@ var (
2626
messageRegex = regexp.MustCompile(`^\s*(?:"([^"]+)"|([^\s<>-]+))\s*(-->>|-->|->>|->)\s*(?:"([^"]+)"|([^\s<>-]+))\s*:\s*(.*)$`)
2727

2828
// autonumberRegex matches the autonumber directive
29-
autonumberRegex = regexp.MustCompile(`^\s*autonumber\s*$`)
29+
autonumberRegex = regexp.MustCompile(`(?i)^\s*autonumber\s*$`)
3030

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(`^\s*(loop|opt|alt)\b\s*(.*)$`)
34+
fragmentStartRegex = regexp.MustCompile(`(?i)^\s*(loop|opt|alt)\b\s*(.*)$`)
3535

3636
// fragmentElseRegex matches an "else" divider inside an alt block. Group 1 is
3737
// the (optional) condition label for the following section.
38-
fragmentElseRegex = regexp.MustCompile(`^\s*else\b\s*(.*)$`)
38+
fragmentElseRegex = regexp.MustCompile(`(?i)^\s*else\b\s*(.*)$`)
3939

4040
// fragmentEndRegex matches the "end" line that closes a fragment.
41-
fragmentEndRegex = regexp.MustCompile(`^\s*end\s*$`)
41+
fragmentEndRegex = regexp.MustCompile(`(?i)^\s*end\s*$`)
4242

4343
// noteRegex matches note annotations: "Note over A: text", "note left of A:
4444
// text", "Note over A,B: text" (case-insensitive keyword). Group 1 is the
4545
// placement, group 2 the participant list, group 3 the text.
46-
noteRegex = regexp.MustCompile(`^\s*[Nn]ote\s+(right of|left of|over)\s+([^:]+?)\s*:\s*(.*)$`)
46+
noteRegex = regexp.MustCompile(`(?i)^\s*note\s+(right of|left of|over)\s+([^:]+?)\s*:\s*(.*)$`)
4747
)
4848

4949
// SequenceDiagram represents a parsed sequence diagram.
@@ -202,11 +202,25 @@ func IsSequenceDiagram(input string) bool {
202202
if trimmed == "" || strings.HasPrefix(trimmed, "%%") {
203203
continue
204204
}
205-
return strings.HasPrefix(trimmed, SequenceDiagramKeyword)
205+
return hasSequenceKeyword(trimmed)
206206
}
207207
return false
208208
}
209209

210+
// hasSequenceKeyword reports whether a line is the sequenceDiagram declaration,
211+
// case-insensitively (mermaid's sequence grammar is case-insensitive). The
212+
// keyword must stand as a whole token — followed by whitespace or end of line —
213+
// so a node id like "sequenceDiagramFoo" in a flowchart isn't misrouted here.
214+
func hasSequenceKeyword(line string) bool {
215+
lower := strings.ToLower(strings.TrimSpace(line))
216+
kw := strings.ToLower(SequenceDiagramKeyword)
217+
if !strings.HasPrefix(lower, kw) {
218+
return false
219+
}
220+
rest := lower[len(kw):]
221+
return rest == "" || rest[0] == ' ' || rest[0] == '\t'
222+
}
223+
210224
func Parse(input string) (*SequenceDiagram, error) {
211225
input = strings.TrimSpace(input)
212226
if input == "" {
@@ -219,7 +233,7 @@ func Parse(input string) (*SequenceDiagram, error) {
219233
return nil, fmt.Errorf("no content found")
220234
}
221235

222-
if !strings.HasPrefix(strings.TrimSpace(lines[0]), SequenceDiagramKeyword) {
236+
if !hasSequenceKeyword(strings.TrimSpace(lines[0])) {
223237
return nil, fmt.Errorf("expected %q keyword", SequenceDiagramKeyword)
224238
}
225239
lines = lines[1:]
@@ -252,7 +266,7 @@ func Parse(input string) (*SequenceDiagram, error) {
252266
// "Note->>B: hi") still parses as a message further down.
253267
if m := noteRegex.FindStringSubmatch(trimmed); m != nil {
254268
placement := NoteOver
255-
switch m[1] {
269+
switch strings.ToLower(m[1]) { // keyword may be any case
256270
case "left of":
257271
placement = NoteLeftOf
258272
case "right of":
@@ -272,7 +286,7 @@ func Parse(input string) (*SequenceDiagram, error) {
272286
// wrapping is irrelevant for single-line ASCII, so just strip it.
273287
text := strings.TrimSpace(m[3])
274288
for _, pre := range []string{"nowrap:", "wrap:"} {
275-
if strings.HasPrefix(text, pre) {
289+
if strings.HasPrefix(strings.ToLower(text), pre) {
276290
text = strings.TrimSpace(text[len(pre):])
277291
break
278292
}
@@ -301,7 +315,7 @@ func Parse(input string) (*SequenceDiagram, error) {
301315

302316
// A fragment opener ("loop"/"opt"/"alt") starts a framed block.
303317
if match := fragmentStartRegex.FindStringSubmatch(trimmed); match != nil {
304-
fType := map[string]FragmentType{"loop": FragmentLoop, "opt": FragmentOpt, "alt": FragmentAlt}[match[1]]
318+
fType := map[string]FragmentType{"loop": FragmentLoop, "opt": FragmentOpt, "alt": FragmentAlt}[strings.ToLower(match[1])]
305319
sd.Events = append(sd.Events, Event{
306320
Kind: EventFragmentStart,
307321
Fragment: &Fragment{Type: fType, Label: strings.TrimSpace(match[2])},

pkg/sequence/sequence_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestIsSequenceDiagram(t *testing.T) {
5757
want bool
5858
}{
5959
{"sequenceDiagram\nA->>B: Hello", true},
60+
{"SEQUENCEDIAGRAM\nA->>B: Hello", true}, // case-insensitive dispatch
61+
{"SequenceDiagram\nA->>B: Hello", true}, // mixed case
62+
{"sequenceDiagramFoo-->B", false}, // token boundary: a node id, not the keyword
6063
{"graph LR\nA-->B", false},
6164
{"graph TD\nA-->B", false},
6265
{"", false},

0 commit comments

Comments
 (0)