@diagrammo/dgmo 0.47.0 → 0.48.1

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 (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. 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
- }