@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.
- package/dist/advanced.js +11 -11
- package/dist/auto.js +16 -16
- package/dist/auto.mjs +41 -12
- package/dist/block.js +4 -4
- package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
- package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
- package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
- package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
- package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
- package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
- package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
- package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
- package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
- package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
- package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
- package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
- package/dist/cli.cjs +15 -15
- package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
- package/dist/element.js +16 -16
- package/dist/element.mjs +41 -12
- package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
- package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
- package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
- package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
- package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
- package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
- package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
- package/package.json +23 -71
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/sequence/parser.ts
DELETED
|
@@ -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
|
-
}
|