@diagrammo/dgmo 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -1,1596 +0,0 @@
1
- // ============================================================
2
- // Sequence Diagram Parser (.dgmo format)
3
- // ============================================================
4
-
5
- import { inferParticipantType } from './participant-inference';
6
- import type { Brand, Writable } from '../utils/brand';
7
- import type { DgmoError } from '../diagnostics';
8
- import type { PaletteColors } from '../palettes/types';
9
- import {
10
- formatDgmoError,
11
- makeDgmoError,
12
- makeFail,
13
- NAME_DIAGNOSTIC_CODES,
14
- nameMergedMessage,
15
- suggest,
16
- } from '../diagnostics';
17
- import { normalizeName, displayName } from '../utils/name-normalize';
18
- import { parseArrow, parseInArrowLabel } from '../utils/arrows';
19
- import {
20
- measureIndent,
21
- extractColor,
22
- splitNameAndMeta,
23
- parseFirstLine,
24
- OPTION_NOCOLON_RE,
25
- warnUnknownMetaKeys,
26
- } from '../utils/parsing';
27
- import {
28
- SEQUENCE_REGISTRY,
29
- withTagAliases,
30
- } from '../utils/reserved-key-registry';
31
- import type { TagGroup } from '../utils/tag-groups';
32
- import {
33
- matchTagBlockHeading,
34
- validateTagValues,
35
- validateTagGroupNames,
36
- stripDefaultModifier,
37
- finalizeAutoTagColors,
38
- AUTO_TAG_COLOR_SENTINEL,
39
- } from '../utils/tag-groups';
40
-
41
- /** Known sequence-diagram options that take a value (space-separated). */
42
- const KNOWN_SEQ_OPTIONS = new Set(['active-tag']);
43
-
44
- /** Known sequence-diagram boolean options (bare keyword or `no-` prefix). */
45
- const KNOWN_SEQ_BOOLEANS = new Set(['solid-fill', 'no-title']);
46
-
47
- /** Boolean options that only support the `no-` prefix form (no bare or key-value). */
48
- const NO_PREFIX_ONLY_BOOLEANS = new Set(['activations']);
49
-
50
- /**
51
- * Participant types that can be declared via "Name is a type" syntax.
52
- *
53
- * The 0.16.0 trim retained only the types whose shapes carry semantic
54
- * weight at a glance: stick figure (actor), cylinder (database),
55
- * dashed cylinder (cache), horizontal pipe (queue), plus the default
56
- * rectangle. Any other type word falls back to `default`.
57
- */
58
- export type ParticipantType =
59
- | 'default'
60
- | 'database'
61
- | 'actor'
62
- | 'queue'
63
- | 'cache';
64
-
65
- const VALID_PARTICIPANT_TYPES: ReadonlySet<string> = new Set([
66
- 'database',
67
- 'actor',
68
- 'queue',
69
- 'cache',
70
- ]);
71
-
72
- /**
73
- * Branded participant identifier — a normalized name string that has
74
- * been minted through `addParticipant` and registered in the parser's
75
- * `participantMap`. Distinct from a raw display label or any other
76
- * `string`, so the type system catches "passed label where id expected"
77
- * at compile time.
78
- */
79
- export type ParticipantId = Brand<string, 'ParticipantId'>;
80
-
81
- /**
82
- * A declared or inferred participant in the sequence diagram.
83
- */
84
- export interface SequenceParticipant {
85
- /** Internal identifier (e.g. "AuthService") */
86
- readonly id: ParticipantId;
87
- /** Display label — first-seen casing/spacing of the name */
88
- readonly label: string;
89
- /** Participant shape type */
90
- readonly type: ParticipantType;
91
- /** Source line number (1-based) */
92
- readonly lineNumber: number;
93
- /** Explicit layout position override (0-based from left, negative from right) */
94
- readonly position?: number;
95
- /** Pipe-delimited tag metadata (e.g. `| role: Gateway`) */
96
- readonly metadata?: Readonly<Record<string, string>>;
97
- }
98
-
99
- /**
100
- * A message between two participants.
101
- *
102
- * `kind: 'message'` is the discriminator for the SequenceElement union.
103
- * Pre-1.0 type addition — Epic 105 Story 105.17.
104
- */
105
- export interface SequenceMessage {
106
- readonly kind: 'message';
107
- readonly from: ParticipantId;
108
- readonly to: ParticipantId;
109
- readonly label: string;
110
- readonly lineNumber: number;
111
- readonly async?: boolean;
112
- /** Pipe-delimited tag metadata (e.g. `| c: Caching`) */
113
- readonly metadata?: Readonly<Record<string, string>>;
114
- }
115
-
116
- /**
117
- * A conditional or loop block in the sequence diagram.
118
- */
119
- export interface ElseIfBranch {
120
- readonly label: string;
121
- readonly children: readonly SequenceElement[];
122
- readonly lineNumber: number;
123
- }
124
-
125
- export interface SequenceBlock {
126
- readonly kind: 'block';
127
- readonly type: 'if' | 'loop' | 'parallel';
128
- readonly label: string;
129
- readonly children: readonly SequenceElement[];
130
- readonly elseChildren: readonly SequenceElement[];
131
- readonly elseIfBranches?: readonly ElseIfBranch[];
132
- readonly elseLineNumber?: number;
133
- readonly lineNumber: number;
134
- }
135
-
136
- /**
137
- * A labeled horizontal divider between message phases.
138
- */
139
- export interface SequenceSection {
140
- readonly kind: 'section';
141
- readonly label: string;
142
- readonly lineNumber: number;
143
- }
144
-
145
- /**
146
- * An annotation attached to a message, rendered as a folded-corner box.
147
- */
148
- export interface SequenceNote {
149
- readonly kind: 'note';
150
- readonly text: string;
151
- readonly position: 'right' | 'left';
152
- readonly participantId: ParticipantId;
153
- readonly lineNumber: number;
154
- readonly endLineNumber: number;
155
- }
156
-
157
- export type SequenceElement =
158
- | SequenceMessage
159
- | SequenceBlock
160
- | SequenceSection
161
- | SequenceNote;
162
-
163
- export function isSequenceBlock(el: SequenceElement): el is SequenceBlock {
164
- return el.kind === 'block';
165
- }
166
-
167
- export function isSequenceSection(el: SequenceElement): el is SequenceSection {
168
- return el.kind === 'section';
169
- }
170
-
171
- export function isSequenceNote(el: SequenceElement): el is SequenceNote {
172
- return el.kind === 'note';
173
- }
174
-
175
- /**
176
- * A named group of participants rendered as a labeled box.
177
- */
178
- export interface SequenceGroup {
179
- readonly name: string;
180
- readonly participantIds: readonly ParticipantId[];
181
- readonly lineNumber: number;
182
- /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */
183
- readonly metadata?: Readonly<Record<string, string>>;
184
- /** Whether this group is collapsed by default */
185
- readonly collapsed?: boolean;
186
- }
187
-
188
- /**
189
- * Parsed result from a .dgmo sequence diagram.
190
- */
191
- export interface ParsedSequenceDgmo {
192
- readonly title: string | null;
193
- readonly titleLineNumber: number | null;
194
- readonly participants: readonly SequenceParticipant[];
195
- readonly messages: readonly SequenceMessage[];
196
- readonly elements: readonly SequenceElement[];
197
- readonly groups: readonly SequenceGroup[];
198
- readonly sections: readonly SequenceSection[];
199
- readonly tagGroups: readonly TagGroup[];
200
- readonly options: Readonly<Record<string, string>>;
201
- readonly diagnostics: readonly DgmoError[];
202
- readonly error: string | null;
203
- }
204
-
205
- // "Name is a type" pattern — e.g. "Auth Server is a database"
206
- // Participant names may contain spaces; [^:]+? stops at colons so that
207
- // note lines like "note right of A: this is an actor" are not falsely matched.
208
- // Remainder after type carries the optional `as <alias>` modifier.
209
- const IS_A_PATTERN = /^([^:]+?)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
210
-
211
- // Colored participant declaration — e.g. "Tapin2(green)", "API(blue)"
212
- // Scoped to recognized 11-name palette colors only (§1.5) so legitimate
213
- // `funcCall(arg)` lines don't trigger the legacy-color diagnostic.
214
- const COLORED_PARTICIPANT_PATTERN =
215
- /^(\S+?)\((red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white)\)\s*$/;
216
-
217
- // Group heading pattern — "[Backend]", "[Backend] | t: Product"
218
- // Group 1: name (no ] or | inside brackets), Group 2: color in parens, Group 3: after-bracket text
219
- const GROUP_HEADING_PATTERN = /^\[([^\]|]+?)(?:\(([^)]+)\))?\]\s*(.*)$/;
220
- // Fallback: allows anything inside brackets (used to detect pipe-inside-brackets error)
221
- const GROUP_HEADING_FALLBACK = /^\[([^\]]+)\]\s*(.*)$/;
222
-
223
- // Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
224
- const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
225
-
226
- // Arrow pattern for sequence inference — detects any arrow form
227
- const ARROW_PATTERN = /\S+\s*(?:<-\S+-|<~\S+~|-\S+->|~\S+~>|->|~>|<-|<~)\s*\S+/;
228
-
229
- // Note patterns — colon-free syntax only
230
- // Single-line: "note text", "note left text", "note right of X text", "note left X text"
231
- // Multi-line: "note", "note right", "note right of X", "note left X" (body indented below)
232
- //
233
- // The colon-free positioned form requires participant resolution — the parser
234
- // already has participant collection infrastructure, so we match the general
235
- // structure here and resolve participant vs text in the parsing logic.
236
- const NOTE_BARE = /^note\s+(.+)$/i;
237
- const NOTE_MULTI = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*$/i;
238
-
239
- /** Result of parseNoteLine — indicates what the parser should do. */
240
- type NoteParseResult =
241
- | {
242
- kind: 'single';
243
- position: 'right' | 'left';
244
- participantId: ParticipantId;
245
- text: string;
246
- }
247
- | {
248
- kind: 'multi-head';
249
- position: 'right' | 'left';
250
- participantId: ParticipantId;
251
- }
252
- | { kind: 'skip' }
253
- | null; // not a note line at all
254
-
255
- /**
256
- * Parse a note line, resolving participant names from the known participants list.
257
- *
258
- * Supports:
259
- * - `note text` — default position (right), last msg sender
260
- * - `note left of X text` / `note left X text`
261
- * - `note right` — multi-line head
262
- * - `note right of X` / `note left X` — multi-line head
263
- * - Quoted participant: `note left "Auth Service" text`
264
- */
265
- function parseNoteLine(
266
- trimmed: string,
267
- participants: SequenceParticipant[],
268
- participantIds: Set<string>,
269
- sortedParticipantsCache: SequenceParticipant[],
270
- lastMsgFrom: string | null
271
- ): NoteParseResult {
272
- const lower = trimmed.toLowerCase();
273
- if (!lower.startsWith('note')) return null;
274
- // Must be exactly "note" or "note " — not "notebook" etc.
275
- if (trimmed.length > 4 && trimmed[4] !== ' ') return null;
276
-
277
- // 1. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`
278
- // NOTE: NOTE_MULTI's (.+?) can greedily capture "participant text" as one group.
279
- // Only trust this match if the captured participant actually exists. Otherwise,
280
- // fall through to the bare-note handler which does proper participant-aware splitting.
281
- const multiMatch = trimmed.match(NOTE_MULTI);
282
- if (multiMatch) {
283
- const position =
284
- (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
285
- let participantId = multiMatch[2] || null;
286
- if (!participantId) {
287
- if (!lastMsgFrom) return { kind: 'skip' };
288
- participantId = lastMsgFrom;
289
- }
290
- const lookupKey = normalizeName(participantId);
291
- if (participantIds.has(lookupKey)) {
292
- // Resolve to first-seen display id so the renderer can find it
293
- const found = participants.find((p) => normalizeName(p.id) === lookupKey);
294
- return {
295
- kind: 'multi-head',
296
- position,
297
- // Verified participant — `participantIds.has(lookupKey)` confirmed.
298
- participantId: found?.id ?? (participantId as ParticipantId),
299
- };
300
- }
301
- // Participant not found — fall through to bare-note handler for proper resolution
302
- }
303
-
304
- // 2. Bare note: `note text` or `note left [of] X text`
305
- const bareMatch = trimmed.match(NOTE_BARE);
306
- if (bareMatch) {
307
- // Capture group 1 guaranteed present after successful match.
308
- const rest = bareMatch[1]!.trim();
309
- const restLower = rest.toLowerCase();
310
-
311
- // Check for positioned note: `note left/right ...`
312
- if (restLower.startsWith('left') || restLower.startsWith('right')) {
313
- const posWord = restLower.startsWith('left') ? 'left' : 'right';
314
- const position = posWord as 'right' | 'left';
315
- let afterPos = rest.substring(posWord.length).trim();
316
-
317
- // Strip optional `of` keyword — track whether it was present
318
- let hadOf = false;
319
- if (afterPos.toLowerCase().startsWith('of ')) {
320
- afterPos = afterPos.substring(3).trim();
321
- hadOf = true;
322
- }
323
-
324
- if (!afterPos) {
325
- // Just `note left` or `note right` — multi-line head
326
- if (!lastMsgFrom) return { kind: 'skip' };
327
- if (!participantIds.has(normalizeName(lastMsgFrom)))
328
- return { kind: 'skip' };
329
- // Verified — participantIds.has confirmed lastMsgFrom is registered.
330
- return {
331
- kind: 'multi-head',
332
- position,
333
- participantId: lastMsgFrom as ParticipantId,
334
- };
335
- }
336
-
337
- // Try to match a known participant at the start of afterPos
338
- const resolved = resolveParticipantAndText(
339
- afterPos,
340
- participants,
341
- participantIds,
342
- sortedParticipantsCache
343
- );
344
- if (resolved) {
345
- if (resolved.text) {
346
- return {
347
- kind: 'single',
348
- position,
349
- participantId: resolved.participantId,
350
- text: resolved.text,
351
- };
352
- } else {
353
- // No text after participant — multi-line head
354
- return {
355
- kind: 'multi-head',
356
- position,
357
- participantId: resolved.participantId,
358
- };
359
- }
360
- }
361
-
362
- // No known participant matched.
363
- // If `of` was explicit (`note right of Z ...`), the user intended a specific
364
- // participant — skip when it doesn't exist rather than defaulting.
365
- if (hadOf) return { kind: 'skip' };
366
-
367
- // Without `of`, treat remaining text as note content on the last-msg sender
368
- if (!lastMsgFrom) return { kind: 'skip' };
369
- if (!participantIds.has(normalizeName(lastMsgFrom)))
370
- return { kind: 'skip' };
371
- return {
372
- kind: 'single',
373
- position,
374
- // Verified by participantIds.has check above.
375
- participantId: lastMsgFrom as ParticipantId,
376
- text: afterPos,
377
- };
378
- }
379
-
380
- // Plain `note text` — default position, last msg sender
381
- if (!lastMsgFrom) return { kind: 'skip' };
382
- if (!participantIds.has(normalizeName(lastMsgFrom)))
383
- return { kind: 'skip' };
384
- return {
385
- kind: 'single',
386
- position: 'right',
387
- // Verified by participantIds.has check above.
388
- participantId: lastMsgFrom as ParticipantId,
389
- text: rest,
390
- };
391
- }
392
-
393
- return null;
394
- }
395
-
396
- /**
397
- * Try to match a known participant name at the start of a string.
398
- * Returns the matched participant and remaining text, or null if no match.
399
- * Tries longest match first (multi-word participant names).
400
- */
401
- function resolveParticipantAndText(
402
- input: string,
403
- participants: SequenceParticipant[],
404
- participantIds: Set<string>,
405
- sortedParticipantsCache: SequenceParticipant[]
406
- ): { participantId: ParticipantId; text: string } | null {
407
- // Handle quoted participant: `"Auth Service" text`
408
- if (input.startsWith('"') || input.startsWith("'")) {
409
- // input[0] guaranteed by the startsWith check above.
410
- const quote = input[0]!;
411
- const endQuote = input.indexOf(quote, 1);
412
- if (endQuote > 0) {
413
- const name = input.substring(1, endQuote);
414
- const key = normalizeName(name);
415
- if (participantIds.has(key)) {
416
- const text = input.substring(endQuote + 1).trim();
417
- const found = participants.find((p) => normalizeName(p.id) === key);
418
- // Verified by participantIds.has(key) above.
419
- return { participantId: found?.id ?? (name as ParticipantId), text };
420
- }
421
- }
422
- return null;
423
- }
424
-
425
- // Use pre-sorted participants (longest display id first) for greedy
426
- // matching. Compare via the shared normalizer so 'auth service text' matches
427
- // a participant declared as 'Auth Service'.
428
- const sorted = sortedParticipantsCache;
429
- for (const p of sorted) {
430
- const idLen = p.id.length;
431
- if (input.length < idLen) continue;
432
- const candidate = input.substring(0, idLen);
433
- if (normalizeName(candidate) !== normalizeName(p.id)) continue;
434
- const remaining = input.substring(idLen);
435
- // Must be followed by whitespace, end of string, or nothing
436
- if (remaining === '' || remaining[0] === ' ' || remaining[0] === '\t') {
437
- return { participantId: p.id, text: remaining.trim() };
438
- }
439
- }
440
- return null;
441
- }
442
-
443
- /**
444
- * Parse a .dgmo file with `chart: sequence` into a structured representation.
445
- */
446
- export function parseSequenceDgmo(
447
- content: string,
448
- palette?: PaletteColors
449
- ): ParsedSequenceDgmo {
450
- // Diagram-level options accumulator (Readonly<Record<...>> on the public
451
- // type; mutated locally during parse and assigned back via `result.options`).
452
- const optionsAccumulator: Record<string, string> = {};
453
- const result: Writable<ParsedSequenceDgmo> & {
454
- options: Record<string, string>;
455
- } = {
456
- title: null,
457
- titleLineNumber: null,
458
- participants: [],
459
- messages: [],
460
- elements: [],
461
- groups: [],
462
- sections: [],
463
- tagGroups: [],
464
- options: optionsAccumulator,
465
- diagnostics: [],
466
- error: null,
467
- };
468
-
469
- // Per-parse entity alias literal → canonical participantId map
470
- // (TD-18). Distinct from `aliasMap` further down, which is for
471
- // tag-group aliases (per the A1 naming convention). Per C8:
472
- // never persisted, fresh each parse.
473
- const nameAliasMap = new Map<string, string>();
474
- const resolveAlias = (token: string): string => {
475
- const trimmed = token.trim();
476
- return nameAliasMap.get(trimmed) ?? trimmed;
477
- };
478
-
479
- const fail = makeFail(result);
480
-
481
- /** Push a recoverable error and continue parsing. */
482
- const pushError = (line: number, message: string): void => {
483
- const diag = makeDgmoError(line, message);
484
- result.diagnostics.push(diag);
485
- if (!result.error) result.error = formatDgmoError(diag);
486
- };
487
-
488
- /** Push a non-fatal warning (does not set result.error). */
489
- const pushWarning = (line: number, message: string): void => {
490
- result.diagnostics.push(makeDgmoError(line, message, 'warning'));
491
- };
492
-
493
- if (!content?.trim()) {
494
- return fail(0, 'Empty content');
495
- }
496
-
497
- const lines = content.split('\n');
498
- let hasExplicitChart = false;
499
- let contentStarted = false;
500
- // Whether the message body has begun (first message, section, block, or note).
501
- // Unlike `contentStarted` — which any declaration trips to close the
502
- // options/tag-group "headers first" window — `bodyStarted` stays false through
503
- // the entire declaration preamble so bare and typed participant declarations
504
- // can be freely interleaved. It gates bare-name declarations: once real body
505
- // content appears, a bare word is treated as a stray line, not a participant.
506
- let bodyStarted = false;
507
- let firstLineIndex = -1; // line index of the `sequence [Title]` first line (to skip in main loop)
508
-
509
- // Handle first non-empty, non-comment line for `sequence Title` syntax
510
- for (let fi = 0; fi < lines.length; fi++) {
511
- // In-bounds by loop guard.
512
- const fl = lines[fi]!.trim();
513
- if (!fl || fl.startsWith('//')) continue;
514
- const parsed = parseFirstLine(fl);
515
- if (parsed?.chartType === 'sequence') {
516
- hasExplicitChart = true;
517
- firstLineIndex = fi;
518
- if (parsed.title) {
519
- result.title = parsed.title;
520
- result.titleLineNumber = fi + 1;
521
- }
522
- }
523
- break;
524
- }
525
-
526
- // Group parsing state — tracks the active [Group] heading.
527
- // Mutated during parse (participantIds.push); typed Writable so the
528
- // local accumulator can grow before the readonly-typed value is exposed.
529
- let activeGroup: Writable<SequenceGroup> | null = null;
530
-
531
- // Fast lookup set for participant existence checks (mirrors result.participants).
532
- // Holds NORMALIZED participant keys so 'Auth Service' and 'auth service' fold
533
- // to the same entry. Display label survives in result.participants[i].label.
534
- const participantIds = new Set<string>();
535
-
536
- // Map<normalizedKey, first-seen participant.id> — the canonical
537
- // identifier callers store on messages, group memberships, etc.
538
- const idByKey = new Map<string, string>();
539
-
540
- /**
541
- * Get-or-create a participant by normalized key. Returns the FIRST-SEEN
542
- * `participant.id` — which is the user's original casing/spacing.
543
- * Callers should use the returned value on messages, group memberships,
544
- * notes, etc. so all references resolve to the canonical id.
545
- *
546
- * Emits NAME_MERGED warning when an incoming source label normalizes to
547
- * an existing key but its display form differs (case/whitespace).
548
- */
549
- const addParticipant = (
550
- name: string,
551
- lineNumber: number,
552
- extras?: {
553
- type?: ParticipantType;
554
- position?: number;
555
- label?: string;
556
- metadata?: Record<string, string>;
557
- }
558
- ): ParticipantId => {
559
- const key = normalizeName(name);
560
- const trimmed = name.trim() as ParticipantId;
561
- const incomingLabel = extras?.label ?? trimmed;
562
- if (participantIds.has(key)) {
563
- const existing = result.participants.find(
564
- (p) => normalizeName(p.id) === key
565
- );
566
- if (existing) {
567
- if (
568
- displayName(existing.label) !== displayName(incomingLabel) ||
569
- displayName(existing.id) !== displayName(trimmed)
570
- ) {
571
- result.diagnostics.push(
572
- makeDgmoError(
573
- lineNumber,
574
- nameMergedMessage({
575
- incomingDisplay: trimmed,
576
- incomingLine: lineNumber,
577
- existingDisplay: existing.id,
578
- existingLine: existing.lineNumber,
579
- }),
580
- 'warning',
581
- NAME_DIAGNOSTIC_CODES.NAME_MERGED
582
- )
583
- );
584
- }
585
- return existing.id;
586
- }
587
- // Fallback: idByKey lookup hit but participant somehow missing.
588
- return (idByKey.get(key) ?? trimmed) as ParticipantId;
589
- }
590
- participantIds.add(key);
591
-
592
- idByKey.set(key, trimmed);
593
- sortedCacheDirty = true;
594
- result.participants.push({
595
- id: trimmed,
596
- label: incomingLabel,
597
- type: extras?.type ?? inferParticipantType(name),
598
- lineNumber,
599
- ...(extras?.position !== undefined ? { position: extras.position } : {}),
600
- // takePosition() may have emptied the metadata record (position was
601
- // its only key) — don't attach an empty object.
602
- ...(extras?.metadata && Object.keys(extras.metadata).length > 0
603
- ? { metadata: extras.metadata }
604
- : {}),
605
- });
606
- return trimmed;
607
- };
608
-
609
- // Cache sorted participants (longest ID first) for greedy name matching in notes.
610
- // Invalidated whenever a new participant is added.
611
- let sortedParticipantsCache: SequenceParticipant[] = [];
612
- let sortedCacheDirty = true;
613
-
614
- /** Get sorted participants (longest display label first), rebuilding only when dirty. */
615
- const getSortedParticipants = (): SequenceParticipant[] => {
616
- if (sortedCacheDirty) {
617
- sortedParticipantsCache = [...result.participants].sort(
618
- (a, b) => b.label.length - a.label.length
619
- );
620
- sortedCacheDirty = false;
621
- }
622
- return sortedParticipantsCache;
623
- };
624
-
625
- // Track participant → group name for duplicate membership detection
626
- const participantGroupMap = new Map<string, string>();
627
-
628
- // Tag group parsing state
629
- let currentTagGroup: Writable<TagGroup> | null = null;
630
- const aliasMap = new Map<string, string>();
631
-
632
- /**
633
- * Split same-line metadata from a line: "core k: v" (§1.4)
634
- * → { core, meta }. Dispatched via splitNameAndMeta + SEQUENCE_REGISTRY.
635
- */
636
- const splitPipe = (
637
- text: string,
638
- ln?: number
639
- ): { core: string; meta?: Record<string, string>; alias?: string } => {
640
- // §1.4 unified metadata grammar — same-line cut, no pipe.
641
- const registry = withTagAliases(
642
- SEQUENCE_REGISTRY,
643
- new Set(aliasMap.keys())
644
- );
645
- const split = splitNameAndMeta(text, registry, aliasMap);
646
- if (ln != null) {
647
- warnUnknownMetaKeys(
648
- split.meta,
649
- registry,
650
- (msg) => pushWarning(ln, msg),
651
- split.name
652
- );
653
- }
654
- if (Object.keys(split.meta).length > 0) {
655
- // splitNameAndMeta peels a trailing `as <alias>` off the name region
656
- // when same-line metadata is present (the alias must precede the meta,
657
- // which runs to EOL). Propagate it so callers can register the alias —
658
- // the no-meta branch below leaves `as <alias>` in `core` for the
659
- // caller's own peeling.
660
- return {
661
- core: split.name,
662
- meta: split.meta,
663
- ...(split.alias !== undefined && { alias: split.alias }),
664
- };
665
- }
666
- return { core: text };
667
- };
668
-
669
- /**
670
- * Pull the layout-order `position:` key out of a participant's
671
- * parsed metadata (§2.2). Position is a layout directive, not display
672
- * metadata, so it is *removed* from the record after extraction. A
673
- * non-integer value raises an error and yields no position.
674
- */
675
- const takePosition = (
676
- meta: Record<string, string> | undefined,
677
- ln: number
678
- ): number | undefined => {
679
- if (meta?.['position'] === undefined) return undefined;
680
- const raw = meta['position'];
681
- delete meta['position'];
682
- const n = parseInt(raw, 10);
683
- if (!/^-?\d+$/.test(raw.trim()) || Number.isNaN(n)) {
684
- pushError(ln, `'position: ${raw}' must be an integer slot index`);
685
- return undefined;
686
- }
687
- return n;
688
- };
689
-
690
- // Block parsing state — blocks are built mutably during parse, then
691
- // assigned back into the readonly-typed `ParsedSequenceDgmo`.
692
- const blockStack: {
693
- block: Writable<SequenceBlock>;
694
- indent: number;
695
- inElse: boolean;
696
- activeElseIfBranch?: Writable<ElseIfBranch>;
697
- }[] = [];
698
- const currentContainer = (): SequenceElement[] => {
699
- if (blockStack.length === 0) return result.elements;
700
- // In-bounds by the length check above.
701
- const top = blockStack[blockStack.length - 1]!;
702
- if (top.activeElseIfBranch) return top.activeElseIfBranch.children;
703
- return top.inElse ? top.block.elseChildren : top.block.children;
704
- };
705
-
706
- // Track last message sender for default note positioning
707
- let lastMsgFrom: string | null = null;
708
-
709
- for (let i = 0; i < lines.length; i++) {
710
- // In-bounds by loop guard.
711
- const raw = lines[i]!;
712
- const trimmed = raw.trim();
713
- const lineNumber = i + 1;
714
-
715
- // Skip empty lines
716
- if (!trimmed) {
717
- activeGroup = null;
718
- currentTagGroup = null;
719
- continue;
720
- }
721
-
722
- // Skip first line already handled as `sequence [Title]`
723
- if (i === firstLineIndex) continue;
724
-
725
- // Parse group heading — [Group Name] or [Group Name] | k: v
726
- const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);
727
- if (groupMatch) {
728
- // Capture group 1 guaranteed present after successful match.
729
- const groupName = groupMatch[1]!.trim();
730
- const groupColor = groupMatch[2]?.trim();
731
- let groupMeta: Record<string, string> | undefined;
732
-
733
- // Parse metadata and collapse state AFTER the closing bracket
734
- const afterBracket = groupMatch[3]?.trim() || '';
735
- let isCollapsed = false;
736
-
737
- // §1.4 same-line metadata on group header
738
- if (afterBracket) {
739
- const groupRegistry = withTagAliases(
740
- SEQUENCE_REGISTRY,
741
- new Set(aliasMap.keys())
742
- );
743
- const split = splitNameAndMeta(afterBracket, groupRegistry, aliasMap);
744
- warnUnknownMetaKeys(
745
- split.meta,
746
- groupRegistry,
747
- (msg) => pushWarning(lineNumber, msg),
748
- split.name
749
- );
750
- if (Object.keys(split.meta).length > 0) groupMeta = split.meta;
751
- if (groupMeta?.['collapsed']?.toLowerCase() === 'true') {
752
- isCollapsed = true;
753
- delete groupMeta['collapsed'];
754
- if (Object.keys(groupMeta).length === 0) groupMeta = undefined;
755
- }
756
- }
757
-
758
- if (groupColor) {
759
- pushWarning(
760
- lineNumber,
761
- `'(${groupColor})' parens-color syntax removed from sequence diagrams — use 'tag:' groups for coloring`
762
- );
763
- }
764
- contentStarted = true;
765
- activeGroup = {
766
- name: groupName,
767
- participantIds: [],
768
- lineNumber,
769
- ...(groupMeta ? { metadata: groupMeta } : {}),
770
- ...(isCollapsed ? { collapsed: true } : {}),
771
- };
772
- result.groups.push(activeGroup);
773
- continue;
774
- }
775
-
776
- // Detect pipe-inside-brackets error: [Name | meta] → suggest [Name] | meta
777
- if (trimmed.startsWith('[')) {
778
- const fallbackMatch = trimmed.match(GROUP_HEADING_FALLBACK);
779
- // Capture group 1 guaranteed present after successful match.
780
- if (fallbackMatch && fallbackMatch[1]!.includes('|')) {
781
- const rawInside = fallbackMatch[1]!;
782
- const pipeIdx = rawInside.indexOf('|');
783
- const cleanName = rawInside
784
- .substring(0, pipeIdx)
785
- .trim()
786
- .replace(/\([^)]*\)$/, '')
787
- .trim();
788
- const metaPart = rawInside
789
- .substring(pipeIdx)
790
- .replace(/^\|\s*/, '')
791
- .trim();
792
- pushError(
793
- lineNumber,
794
- `Metadata goes after the bracket with no pipe — use '[${cleanName}] ${metaPart}' (e.g. '[Backend] t: Product'), not '[${rawInside.trim()}]'`
795
- );
796
- continue;
797
- }
798
- }
799
-
800
- // Close active group on non-indented, non-group lines
801
- if (activeGroup && measureIndent(raw) === 0) {
802
- activeGroup = null;
803
- }
804
-
805
- // Skip comments — only // is supported
806
- if (trimmed.startsWith('//')) continue;
807
-
808
- // Reject # as comment syntax
809
- if (trimmed.startsWith('#')) {
810
- pushError(lineNumber, 'Use // for comments');
811
- continue;
812
- }
813
-
814
- // ---- Tag group handling ----
815
- // Tag block heading: "tag Name as <alias>"
816
- const tagBlockMatch = matchTagBlockHeading(trimmed);
817
- if (tagBlockMatch) {
818
- if (contentStarted) {
819
- pushError(lineNumber, 'Tag groups must appear before sequence content');
820
- continue;
821
- }
822
- const newTagGroup: Writable<TagGroup> = {
823
- name: tagBlockMatch.name,
824
- ...(tagBlockMatch.alias !== undefined && {
825
- alias: tagBlockMatch.alias,
826
- }),
827
- entries: [],
828
- lineNumber,
829
- };
830
- currentTagGroup = newTagGroup;
831
- if (tagBlockMatch.alias) {
832
- aliasMap.set(
833
- tagBlockMatch.alias.toLowerCase(),
834
- tagBlockMatch.name.toLowerCase()
835
- );
836
- }
837
- aliasMap.set(
838
- normalizeName(tagBlockMatch.name),
839
- tagBlockMatch.name.toLowerCase()
840
- );
841
- result.tagGroups.push(newTagGroup);
842
- continue;
843
- }
844
-
845
- // Tag group entries (indented Value color under tag heading)
846
- // First entry is the default unless another is marked `default`
847
- if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {
848
- const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
849
- const { label, color } = extractColor(
850
- cleanEntry,
851
- palette,
852
- result.diagnostics,
853
- lineNumber
854
- );
855
- // Bare value (no explicit color) → keep it; finalized below.
856
- if (isDefault) {
857
- currentTagGroup.defaultValue = label;
858
- } else if (currentTagGroup.entries.length === 0) {
859
- currentTagGroup.defaultValue = label;
860
- }
861
- currentTagGroup.entries.push({
862
- value: label,
863
- color: color ?? AUTO_TAG_COLOR_SENTINEL,
864
- lineNumber,
865
- });
866
- continue;
867
- }
868
-
869
- // Non-indented line after tag group — close it
870
- if (currentTagGroup) {
871
- currentTagGroup = null;
872
- }
873
-
874
- // Parse section dividers — "== Label ==" or "== Label(color) =="
875
- // Close blocks first — sections at indent 0 should not nest inside blocks
876
- const sectionMatch = trimmed.match(SECTION_PATTERN);
877
- if (sectionMatch) {
878
- const sectionIndent = measureIndent(raw);
879
- while (blockStack.length > 0) {
880
- // In-bounds by the length check above.
881
- const top = blockStack[blockStack.length - 1]!;
882
- if (sectionIndent > top.indent) break;
883
- blockStack.pop();
884
- }
885
- // Capture group 1 guaranteed present after successful match.
886
- const labelRaw = sectionMatch[1]!.trim();
887
- // Scoped to recognized 11-name palette colors only (§1.5).
888
- const colorMatch = labelRaw.match(
889
- /^(.+?)\((red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white)\)$/
890
- );
891
- if (colorMatch) {
892
- pushWarning(
893
- lineNumber,
894
- `'(${colorMatch[2]})' parens-color syntax removed from sequence diagrams — use 'tag:' groups for coloring`
895
- );
896
- }
897
- contentStarted = true;
898
- bodyStarted = true;
899
- const section: SequenceSection = {
900
- kind: 'section',
901
- // Capture group 1 guaranteed present after successful match.
902
- label: colorMatch ? colorMatch[1]!.trim() : labelRaw,
903
- lineNumber,
904
- };
905
- result.sections.push(section);
906
- currentContainer().push(section);
907
- continue;
908
- }
909
-
910
- // Parse header key: value lines (always top-level)
911
- // Skip 'note' lines — parsed in the indent-aware section below
912
- const colonIndex = trimmed.indexOf(':');
913
- // §1.4: a real header key is a single identifier. Participant
914
- // lines with same-line metadata (`CheckoutSvc o: Checkout`) have
915
- // a space inside the pre-colon region — those are content lines,
916
- // not header options.
917
- const preColon =
918
- colonIndex > 0 ? trimmed.substring(0, colonIndex).trim() : '';
919
- const preColonIsSingleIdent = /^[A-Za-z][\w-]*$/.test(preColon);
920
- if (
921
- colonIndex > 0 &&
922
- preColonIsSingleIdent &&
923
- !trimmed.includes('->') &&
924
- !trimmed.includes('~>') &&
925
- !trimmed.includes('<-') &&
926
- !trimmed.includes('<~') &&
927
- !trimmed.includes('|')
928
- ) {
929
- const key = preColon.toLowerCase();
930
- if (key === 'note' || key.startsWith('note ')) {
931
- // Fall through to indent-aware note parsing below
932
- } else {
933
- const value = trimmed.substring(colonIndex + 1).trim();
934
-
935
- // Enforce headers-before-content
936
- if (contentStarted) {
937
- pushError(
938
- lineNumber,
939
- `Options like '${key}: ${value}' must appear before the first message or declaration`
940
- );
941
- continue;
942
- }
943
-
944
- if (key === 'title') {
945
- result.title = value;
946
- result.titleLineNumber = lineNumber;
947
- continue;
948
- }
949
-
950
- // Store other options
951
- result.options[key] = value;
952
- continue;
953
- }
954
- }
955
-
956
- // Parse space-separated options (no colon): `no-activations`, `solid-fill`, `active-tag Priority`
957
- {
958
- const optLower = trimmed.toLowerCase();
959
- // Negated boolean: `no-activations` → options.activations = 'off'
960
- if (optLower.startsWith('no-')) {
961
- const base = optLower.substring(3);
962
- if (KNOWN_SEQ_BOOLEANS.has(base) || NO_PREFIX_ONLY_BOOLEANS.has(base)) {
963
- if (contentStarted) {
964
- pushError(
965
- lineNumber,
966
- `Options like '${trimmed}' must appear before the first message or declaration`
967
- );
968
- continue;
969
- }
970
- result.options[base] = 'off';
971
- continue;
972
- }
973
- }
974
- // Key-value option: `active-tag Priority`
975
- const spaceMatch = trimmed.match(OPTION_NOCOLON_RE);
976
- if (spaceMatch) {
977
- // Capture groups 1 and 2 guaranteed present after successful match.
978
- const optKey = spaceMatch[1]!.toLowerCase();
979
- const optVal = spaceMatch[2]!.trim();
980
- if (KNOWN_SEQ_OPTIONS.has(optKey) || KNOWN_SEQ_BOOLEANS.has(optKey)) {
981
- if (contentStarted) {
982
- pushError(
983
- lineNumber,
984
- `Options like '${trimmed}' must appear before the first message or declaration`
985
- );
986
- continue;
987
- }
988
- result.options[optKey] = optVal;
989
- continue;
990
- }
991
- }
992
- // Bare boolean keyword: `solid-fill` / `no-title`
993
- if (
994
- KNOWN_SEQ_BOOLEANS.has(optLower) &&
995
- (optLower === 'solid-fill' || optLower === 'no-title')
996
- ) {
997
- if (contentStarted) {
998
- pushError(
999
- lineNumber,
1000
- `Options like '${trimmed}' must appear before the first message or declaration`
1001
- );
1002
- continue;
1003
- }
1004
- result.options[optLower] = 'on';
1005
- continue;
1006
- }
1007
- }
1008
-
1009
- // Parse "Name is a type [position N]" declarations (always top-level).
1010
- // The legacy `aka Alias` modifier is no longer supported — Universal Name
1011
- // Handling makes aliasing unnecessary because forgiving normalization
1012
- // handles casing/whitespace differences automatically.
1013
- // Skip lines starting with 'note' — handled by note parsing below.
1014
- const {
1015
- core: isACore,
1016
- meta: isAMeta,
1017
- alias: isAAlias,
1018
- } = splitPipe(trimmed, lineNumber);
1019
- const isAMatch = !/^note(\s|$)/i.test(trimmed)
1020
- ? isACore.match(IS_A_PATTERN)
1021
- : null;
1022
- if (isAMatch) {
1023
- contentStarted = true;
1024
- // Capture groups 1 and 2 guaranteed present after successful match.
1025
- const id = isAMatch[1]!;
1026
- const typeStr = isAMatch[2]!.toLowerCase();
1027
- const remainder = isAMatch[3]?.trim() || '';
1028
-
1029
- const participantType: ParticipantType = VALID_PARTICIPANT_TYPES.has(
1030
- typeStr
1031
- )
1032
- ? (typeStr as ParticipantType)
1033
- : 'default';
1034
-
1035
- // TD-18: extract trailing `as <alias>` and register it. Order on the
1036
- // line is `Name is a TYPE as <alias> [position: N]`. When same-line
1037
- // metadata is present, splitPipe already peeled the alias (it must
1038
- // precede the metadata) and handed it back as `isAAlias`; otherwise
1039
- // the alias is still in `remainder` for us to peel here.
1040
- if (isAAlias !== undefined) {
1041
- nameAliasMap.set(isAAlias, id);
1042
- }
1043
- const asInRemainder = remainder.match(
1044
- /^(.*?)\s*\bas\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*$/
1045
- );
1046
- if (asInRemainder) {
1047
- // Capture group 2 guaranteed present after successful match.
1048
- nameAliasMap.set(asInRemainder[2]!, id);
1049
- }
1050
-
1051
- // Position is colon-keyed metadata (§2.2) — pull it from the parsed meta.
1052
- const position = takePosition(isAMeta, lineNumber);
1053
-
1054
- // Avoid duplicate participant declarations
1055
- const key = addParticipant(id, lineNumber, {
1056
- type: participantType,
1057
- ...(position !== undefined && { position }),
1058
- ...(isAMeta !== undefined && { metadata: isAMeta }),
1059
- });
1060
- // Track group membership
1061
- if (activeGroup && !activeGroup.participantIds.includes(key)) {
1062
- const existingGroup = participantGroupMap.get(key);
1063
- if (existingGroup) {
1064
- pushError(
1065
- lineNumber,
1066
- `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
1067
- );
1068
- } else {
1069
- activeGroup.participantIds.push(key);
1070
- // participantGroupMap is keyed by normalized participant key
1071
-
1072
- participantGroupMap.set(key, activeGroup.name);
1073
- }
1074
- }
1075
- continue;
1076
- }
1077
-
1078
- // Legacy `Name(color)` participant declaration at any level (§1.5 hard
1079
- // break). Scoped to the 11-name palette so `funcCall(arg)` doesn't trip.
1080
- const { core: colorCore, meta: colorMeta } = splitPipe(trimmed, lineNumber);
1081
- const coloredMatch = colorCore.match(COLORED_PARTICIPANT_PATTERN);
1082
- if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
1083
- // Capture groups 1 and 2 guaranteed present after successful match.
1084
- const id = coloredMatch[1]!;
1085
- const color = coloredMatch[2]!.trim();
1086
- pushError(
1087
- lineNumber,
1088
- `'${id}(${color})' parens-color syntax is no longer supported — use 'tag:' groups for coloring`
1089
- );
1090
- contentStarted = true;
1091
- const key = addParticipant(id, lineNumber, {
1092
- ...(colorMeta !== undefined && { metadata: colorMeta }),
1093
- });
1094
- if (activeGroup && !activeGroup.participantIds.includes(key)) {
1095
- const existingGroup = participantGroupMap.get(key);
1096
- if (existingGroup) {
1097
- pushError(
1098
- lineNumber,
1099
- `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
1100
- );
1101
- } else {
1102
- activeGroup.participantIds.push(key);
1103
-
1104
- participantGroupMap.set(key, activeGroup.name);
1105
- }
1106
- }
1107
- continue;
1108
- }
1109
-
1110
- // Bare participant name — either inside an active group (indented) or top-level declaration
1111
- // Supports pipe metadata: " API | c: Gateway" or "Tapin2 | l:Park"
1112
- {
1113
- const {
1114
- core: bareCore,
1115
- meta: bareMeta,
1116
- alias: bareAlias,
1117
- } = splitPipe(trimmed, lineNumber);
1118
- const inGroup = activeGroup && measureIndent(raw) > 0;
1119
- if (
1120
- /^\S+$/.test(bareCore) &&
1121
- !ARROW_PATTERN.test(bareCore) &&
1122
- (inGroup || !bodyStarted || bareMeta)
1123
- ) {
1124
- contentStarted = true;
1125
- const id = bareCore;
1126
- if (bareAlias !== undefined) nameAliasMap.set(bareAlias, id);
1127
- // Colon-keyed `position: N` (§2.2) arrives as metadata here.
1128
- const position = takePosition(bareMeta, lineNumber);
1129
- const key = addParticipant(id, lineNumber, {
1130
- ...(position !== undefined && { position }),
1131
- ...(bareMeta !== undefined && { metadata: bareMeta }),
1132
- });
1133
- if (activeGroup && !activeGroup.participantIds.includes(key)) {
1134
- const existingGroup = participantGroupMap.get(key);
1135
- if (existingGroup) {
1136
- pushError(
1137
- lineNumber,
1138
- `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
1139
- );
1140
- } else {
1141
- activeGroup.participantIds.push(key);
1142
-
1143
- participantGroupMap.set(key, activeGroup.name);
1144
- }
1145
- }
1146
- continue;
1147
- }
1148
- }
1149
-
1150
- // ---- Indent-aware parsing for messages and block keywords ----
1151
- const indent = measureIndent(raw);
1152
-
1153
- // Close blocks whose scope has ended (indent decreased)
1154
- while (blockStack.length > 0) {
1155
- // In-bounds by the length check above.
1156
- const top = blockStack[blockStack.length - 1]!;
1157
- if (indent > top.indent) break;
1158
- // Keep block on stack when 'else' or 'else if' matches current indent — handled below
1159
- if (
1160
- indent === top.indent &&
1161
- (top.block.type === 'if' || top.block.type === 'parallel')
1162
- ) {
1163
- const lower = trimmed.toLowerCase();
1164
- if (lower === 'else' || lower.startsWith('else if ')) break;
1165
- }
1166
- blockStack.pop();
1167
- }
1168
-
1169
- // Split pipe metadata before arrow parsing (arrows use $ anchor)
1170
- const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed, lineNumber);
1171
-
1172
- // Parse message lines first — arrows take priority over keywords
1173
- // Reject "async" keyword prefix — use ~> instead
1174
- const asyncPrefixMatch = arrowCore.match(/^async\s+(.+)$/i);
1175
- // Capture group 1 guaranteed present after successful match.
1176
- if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1]!)) {
1177
- pushError(lineNumber, 'Use ~> for async messages: A ~> B: message');
1178
- continue;
1179
- }
1180
-
1181
- // ---- Labeled arrows: -label->, ~label~> ----
1182
- // Must be checked BEFORE plain arrow patterns to avoid partial matches
1183
- const labeledArrow = parseArrow(arrowCore);
1184
- if (labeledArrow && 'error' in labeledArrow) {
1185
- pushError(lineNumber, labeledArrow.error);
1186
- continue;
1187
- }
1188
- if (labeledArrow) {
1189
- contentStarted = true;
1190
- bodyStarted = true;
1191
- const { from, to, label: rawLabel, async: isAsync } = labeledArrow;
1192
- const fromKey = addParticipant(resolveAlias(from), lineNumber);
1193
- const toKey = addParticipant(resolveAlias(to), lineNumber);
1194
- lastMsgFrom = fromKey;
1195
-
1196
- // TD-13/TD-14: validate in-arrow label characters
1197
- const labelResult = parseInArrowLabel(rawLabel, lineNumber);
1198
- labelResult.diagnostics.forEach((d) => result.diagnostics.push(d));
1199
- const label = labelResult.label ?? rawLabel;
1200
-
1201
- const msg: SequenceMessage = {
1202
- kind: 'message',
1203
- from: fromKey,
1204
- to: toKey,
1205
- label,
1206
- lineNumber,
1207
- ...(isAsync ? { async: true } : {}),
1208
- ...(arrowMeta ? { metadata: arrowMeta } : {}),
1209
- };
1210
- result.messages.push(msg);
1211
- currentContainer().push(msg);
1212
- continue;
1213
- }
1214
-
1215
- // ---- Error: old colon-postfix syntax (A -> B: msg) ----
1216
- const colonPostfixSync = arrowCore.match(
1217
- /^(\S+)\s*->\s*([^\s:]+)\s*:\s*(.+)$/
1218
- );
1219
- const colonPostfixAsync = arrowCore.match(
1220
- /^(\S+)\s*~>\s*([^\s:]+)\s*:\s*(.+)$/
1221
- );
1222
- const colonPostfix = colonPostfixSync || colonPostfixAsync;
1223
- if (colonPostfix) {
1224
- // Capture groups 1-3 guaranteed present after successful match.
1225
- const a = colonPostfix[1]!;
1226
- const b = colonPostfix[2]!;
1227
- const msg = colonPostfix[3]!.trim();
1228
- const arrowChar = colonPostfixAsync ? '~' : '-';
1229
- const arrowEnd = colonPostfixAsync ? '~>' : '->';
1230
- pushError(
1231
- lineNumber,
1232
- `Colon syntax is no longer supported. Use '${a} ${arrowChar}${msg}${arrowEnd} ${b}' instead`
1233
- );
1234
- continue;
1235
- }
1236
-
1237
- // ---- Error: plain bidirectional arrows (A <-> B, A <~> B) ----
1238
- const bidiPlainMatch = arrowCore.match(/^(.+?)\s*(?:<->|<~>)\s*(.+)/);
1239
- if (bidiPlainMatch) {
1240
- pushError(
1241
- lineNumber,
1242
- "Bidirectional arrows are no longer supported. Use two separate lines: 'A -msg-> B' and 'B -msg-> A'"
1243
- );
1244
- continue;
1245
- }
1246
-
1247
- // ---- Deprecated bare return arrows: A <- B, A <~ B ----
1248
- const bareReturnSync = arrowCore.match(/^(.+?)\s*<-\s*(.+)$/);
1249
- const bareReturnAsync = arrowCore.match(/^(.+?)\s*<~\s*(.+)$/);
1250
- const bareReturn = bareReturnSync || bareReturnAsync;
1251
- if (bareReturn) {
1252
- const to = bareReturn[1];
1253
- const from = bareReturn[2];
1254
- pushError(
1255
- lineNumber,
1256
- `Left-pointing arrows are no longer supported. Write '${from} -> ${to}' instead`
1257
- );
1258
- continue;
1259
- }
1260
-
1261
- // ---- Bare (unlabeled) call arrows: A -> B, A ~> B ----
1262
- const bareCallSync = arrowCore.match(/^(.+?)\s*->\s*(.+)$/);
1263
- const bareCallAsync = arrowCore.match(/^(.+?)\s*~>\s*(.+)$/);
1264
- const bareCall = bareCallSync || bareCallAsync;
1265
- if (bareCall) {
1266
- contentStarted = true;
1267
- bodyStarted = true;
1268
- // Capture groups 1 and 2 guaranteed present after successful match.
1269
- const from = bareCall[1]!;
1270
- const to = bareCall[2]!;
1271
- const fromKey = addParticipant(resolveAlias(from), lineNumber);
1272
- const toKey = addParticipant(resolveAlias(to), lineNumber);
1273
- lastMsgFrom = fromKey;
1274
-
1275
- const msg: SequenceMessage = {
1276
- kind: 'message',
1277
- from: fromKey,
1278
- to: toKey,
1279
- label: '',
1280
- lineNumber,
1281
- ...(bareCallAsync ? { async: true } : {}),
1282
- ...(arrowMeta ? { metadata: arrowMeta } : {}),
1283
- };
1284
- result.messages.push(msg);
1285
- currentContainer().push(msg);
1286
- continue;
1287
- }
1288
-
1289
- // Parse 'if <label>' block keyword
1290
- const ifMatch = trimmed.match(/^if\s+(.+)$/i);
1291
- if (ifMatch) {
1292
- contentStarted = true;
1293
- bodyStarted = true;
1294
- const block: Writable<SequenceBlock> = {
1295
- kind: 'block',
1296
- type: 'if',
1297
- // Capture group 1 guaranteed present after successful match.
1298
- label: ifMatch[1]!.trim(),
1299
- children: [],
1300
- elseChildren: [],
1301
- lineNumber,
1302
- };
1303
- currentContainer().push(block);
1304
- blockStack.push({ block, indent, inElse: false });
1305
- continue;
1306
- }
1307
-
1308
- // Parse 'loop <label>' block keyword
1309
- const loopMatch = trimmed.match(/^loop\s+(.+)$/i);
1310
- if (loopMatch) {
1311
- contentStarted = true;
1312
- bodyStarted = true;
1313
- const block: Writable<SequenceBlock> = {
1314
- kind: 'block',
1315
- type: 'loop',
1316
- // Capture group 1 guaranteed present after successful match.
1317
- label: loopMatch[1]!.trim(),
1318
- children: [],
1319
- elseChildren: [],
1320
- lineNumber,
1321
- };
1322
- currentContainer().push(block);
1323
- blockStack.push({ block, indent, inElse: false });
1324
- continue;
1325
- }
1326
-
1327
- // Parse 'parallel [label]' block keyword
1328
- const parallelMatch = trimmed.match(/^parallel(?:\s+(.+))?$/i);
1329
- if (parallelMatch) {
1330
- contentStarted = true;
1331
- bodyStarted = true;
1332
- const block: Writable<SequenceBlock> = {
1333
- kind: 'block',
1334
- type: 'parallel',
1335
- label: parallelMatch[1]?.trim() || '',
1336
- children: [],
1337
- elseChildren: [],
1338
- lineNumber,
1339
- };
1340
- currentContainer().push(block);
1341
- blockStack.push({ block, indent, inElse: false });
1342
- continue;
1343
- }
1344
-
1345
- // Parse 'else if <label>' keyword (must come before bare 'else')
1346
- const elseIfMatch = trimmed.match(/^else\s+if\s+(.+)$/i);
1347
- if (elseIfMatch) {
1348
- if (
1349
- blockStack.length > 0 &&
1350
- // In-bounds by the length check above.
1351
- blockStack[blockStack.length - 1]!.indent === indent
1352
- ) {
1353
- // In-bounds by the length check above.
1354
- const top = blockStack[blockStack.length - 1]!;
1355
- if (top.block.type === 'parallel') {
1356
- pushError(
1357
- lineNumber,
1358
- "parallel blocks don't support else if — list all concurrent messages directly inside the block"
1359
- );
1360
- continue;
1361
- }
1362
- if (top.block.type === 'if') {
1363
- const branch: Writable<ElseIfBranch> = {
1364
- // Capture group 1 guaranteed present after successful match.
1365
- label: elseIfMatch[1]!.trim(),
1366
- children: [],
1367
- lineNumber,
1368
- };
1369
- // `elseIfBranches` is optional on SequenceBlock — Writable<>'s
1370
- // shallow strip doesn't unwrap optional readonly arrays, so seed
1371
- // and access through a typed-mutable local alias.
1372
- if (!top.block.elseIfBranches) top.block.elseIfBranches = [];
1373
- const branches = top.block.elseIfBranches as Writable<ElseIfBranch>[];
1374
- branches.push(branch);
1375
- top.activeElseIfBranch = branch;
1376
- top.inElse = false;
1377
- }
1378
- }
1379
- continue;
1380
- }
1381
-
1382
- // Parse 'else' keyword (only applies to 'if' blocks)
1383
- if (trimmed.toLowerCase() === 'else') {
1384
- if (
1385
- blockStack.length > 0 &&
1386
- // In-bounds by the length check above.
1387
- blockStack[blockStack.length - 1]!.indent === indent
1388
- ) {
1389
- // In-bounds by the length check above.
1390
- const top = blockStack[blockStack.length - 1]!;
1391
- if (top.block.type === 'parallel') {
1392
- pushError(
1393
- lineNumber,
1394
- "parallel blocks don't support else — list all concurrent messages directly inside the block"
1395
- );
1396
- continue;
1397
- }
1398
- if (top.block.type === 'if') {
1399
- top.inElse = true;
1400
- delete top.activeElseIfBranch;
1401
- top.block.elseLineNumber = lineNumber;
1402
- }
1403
- }
1404
- continue;
1405
- }
1406
-
1407
- // 'elif <label>' → suggest 'else if <label>'
1408
- const elifMatch = trimmed.match(/^elif\b\s*(.*)$/i);
1409
- if (elifMatch) {
1410
- // Capture group 1 guaranteed present after successful match.
1411
- const tailRaw = elifMatch[1]!.trim();
1412
- const tail = tailRaw ? ' ' + tailRaw : '';
1413
- pushError(
1414
- lineNumber,
1415
- `'elif' is not a keyword. Did you mean 'else if${tail}'?`
1416
- );
1417
- continue;
1418
- }
1419
-
1420
- // 'else <text>' (where text isn't 'if …') → suggest 'else if <text>'
1421
- const elseLabelMatch = trimmed.match(/^else\s+(.+)$/i);
1422
- if (elseLabelMatch) {
1423
- // Capture group 1 guaranteed present after successful match.
1424
- const tail = elseLabelMatch[1]!.trim();
1425
- pushError(
1426
- lineNumber,
1427
- `'else' does not take a label. Did you mean 'else if ${tail}'?`
1428
- );
1429
- continue;
1430
- }
1431
-
1432
- // ---- Note parsing (space-separated only) ----
1433
- // Strategy:
1434
- // 1. Try bare note: `note text` — position defaults, text is everything after `note`
1435
- // 2. For positioned: `note left [of] X text` — needs participant lookup to split name vs text
1436
- // 3. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)
1437
- {
1438
- const noteParsed = parseNoteLine(
1439
- trimmed,
1440
- result.participants,
1441
- participantIds,
1442
- getSortedParticipants(),
1443
- lastMsgFrom
1444
- );
1445
- if (noteParsed) {
1446
- if (noteParsed.kind === 'single') {
1447
- const note: SequenceNote = {
1448
- kind: 'note',
1449
- text: noteParsed.text,
1450
- position: noteParsed.position,
1451
- participantId: noteParsed.participantId,
1452
- lineNumber,
1453
- endLineNumber: lineNumber,
1454
- };
1455
- bodyStarted = true;
1456
- currentContainer().push(note);
1457
- continue;
1458
- }
1459
- if (noteParsed.kind === 'multi-head') {
1460
- // Collect indented body lines
1461
- const noteLines: string[] = [];
1462
- while (i + 1 < lines.length) {
1463
- // In-bounds by the length check above.
1464
- const nextRaw = lines[i + 1]!;
1465
- const nextTrimmed = nextRaw.trim();
1466
- if (!nextTrimmed) break;
1467
- const nextIndent = measureIndent(nextRaw);
1468
- if (nextIndent <= indent) break;
1469
- noteLines.push(nextTrimmed);
1470
- i++;
1471
- }
1472
- if (noteLines.length === 0) continue; // no body yet — skip during live typing
1473
- const note: SequenceNote = {
1474
- kind: 'note',
1475
- text: noteLines.join('\n'),
1476
- position: noteParsed.position,
1477
- participantId: noteParsed.participantId,
1478
- lineNumber,
1479
- endLineNumber: i + 1, // i has advanced past the body lines (1-based)
1480
- };
1481
- bodyStarted = true;
1482
- currentContainer().push(note);
1483
- continue;
1484
- }
1485
- // 'skip' — note was incomplete (no preceding message, unknown participant)
1486
- continue;
1487
- }
1488
- }
1489
-
1490
- // Catch-all: nothing matched this line
1491
- pushWarning(
1492
- lineNumber,
1493
- `Unexpected line: '${trimmed}'. Expected a message ('A -> B' or 'A -label-> B'), participant ('Name' or 'Name is a actor'), note ('note right of A Text'), section ('== Name =='), or block ('if/loop/parallel <label>').`
1494
- );
1495
- }
1496
-
1497
- // Validate: if no explicit chart line, check for arrow-based inference
1498
- if (!hasExplicitChart && result.messages.length === 0) {
1499
- // Check if raw content has arrow patterns for inference
1500
- const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));
1501
- if (!hasArrows) {
1502
- return fail(1, 'No "sequence" header and no sequence content detected');
1503
- }
1504
- }
1505
-
1506
- // Warn about unused participants (only when the diagram has messages)
1507
- if (result.messages.length > 0) {
1508
- const usedIds = new Set<string>();
1509
- for (const msg of result.messages) {
1510
- // msg.from / msg.to / el.participantId are normalized participant keys
1511
- // (set via addParticipant during parsing)
1512
- // eslint-disable-next-line name-normalize/required-at-insertion
1513
- usedIds.add(msg.from);
1514
- // eslint-disable-next-line name-normalize/required-at-insertion
1515
- usedIds.add(msg.to);
1516
- }
1517
- // Walk elements recursively to find note participant references
1518
- const walkElements = (elements: readonly SequenceElement[]): void => {
1519
- for (const el of elements) {
1520
- if (isSequenceNote(el)) {
1521
- // eslint-disable-next-line name-normalize/required-at-insertion
1522
- usedIds.add(el.participantId);
1523
- } else if (isSequenceBlock(el)) {
1524
- walkElements(el.children);
1525
- walkElements(el.elseChildren);
1526
- if (el.elseIfBranches) {
1527
- for (const branch of el.elseIfBranches) {
1528
- walkElements(branch.children);
1529
- }
1530
- }
1531
- }
1532
- }
1533
- };
1534
- walkElements(result.elements);
1535
-
1536
- for (const p of result.participants) {
1537
- if (!usedIds.has(p.id)) {
1538
- pushWarning(
1539
- p.lineNumber,
1540
- `Participant "${p.label}" is declared but never used in any message or note`
1541
- );
1542
- }
1543
- }
1544
- }
1545
-
1546
- // Warn about empty groups
1547
- for (const group of result.groups) {
1548
- if (group.participantIds.length === 0) {
1549
- pushWarning(
1550
- group.lineNumber,
1551
- `Empty group '${group.name}' — did you mean '== ${group.name} ==' for a section divider?`
1552
- );
1553
- }
1554
- }
1555
-
1556
- // Assign palette colors to bare (colorless) tag values.
1557
- finalizeAutoTagColors(result.tagGroups as Writable<TagGroup>[], palette);
1558
-
1559
- // Validate tag group values on participants and messages
1560
- if (result.tagGroups.length > 0) {
1561
- const entities: Array<{
1562
- metadata: Record<string, string>;
1563
- lineNumber: number;
1564
- }> = [];
1565
- for (const p of result.participants) {
1566
- if (p.metadata)
1567
- entities.push({ metadata: p.metadata, lineNumber: p.lineNumber });
1568
- }
1569
- for (const m of result.messages) {
1570
- if (m.metadata)
1571
- entities.push({ metadata: m.metadata, lineNumber: m.lineNumber });
1572
- }
1573
- for (const g of result.groups) {
1574
- if (g.metadata)
1575
- entities.push({ metadata: g.metadata, lineNumber: g.lineNumber });
1576
- }
1577
- validateTagValues(entities, result.tagGroups, pushWarning, suggest);
1578
- validateTagGroupNames(result.tagGroups, pushWarning, pushError);
1579
- }
1580
-
1581
- return result;
1582
- }
1583
-
1584
- /**
1585
- * Detect whether raw content looks like a sequence diagram.
1586
- * Used by the chart type inference logic.
1587
- */
1588
- export function looksLikeSequence(content: string): boolean {
1589
- if (!content) return false;
1590
- const lines = content.split('\n');
1591
- return lines.some((line) => {
1592
- const trimmed = line.trim();
1593
- if (trimmed.startsWith('//')) return false;
1594
- return ARROW_PATTERN.test(trimmed);
1595
- });
1596
- }