@diagrammo/dgmo 0.8.4 → 0.8.6
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/.claude/commands/dgmo.md +300 -0
- package/.cursorrules +20 -2
- package/.github/copilot-instructions.md +20 -2
- package/.windsurfrules +20 -2
- package/AGENTS.md +23 -3
- package/dist/cli.cjs +191 -189
- package/dist/editor.cjs +5 -18
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +5 -18
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +543 -0
- package/dist/highlight.cjs.map +1 -0
- package/dist/highlight.d.cts +32 -0
- package/dist/highlight.d.ts +32 -0
- package/dist/highlight.js +513 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.cjs +3253 -3356
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -56
- package/dist/index.d.ts +77 -56
- package/dist/index.js +3247 -3349
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +113 -33
- package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
- package/gallery/fixtures/slope.dgmo +7 -6
- package/package.json +26 -6
- package/src/boxes-and-lines/collapse.ts +78 -0
- package/src/boxes-and-lines/layout.ts +319 -0
- package/src/boxes-and-lines/parser.ts +694 -0
- package/src/boxes-and-lines/renderer.ts +848 -0
- package/src/boxes-and-lines/types.ts +40 -0
- package/src/c4/parser.ts +10 -5
- package/src/c4/renderer.ts +232 -56
- package/src/chart.ts +9 -4
- package/src/cli.ts +49 -6
- package/src/completion.ts +25 -33
- package/src/d3.ts +187 -46
- package/src/dgmo-router.ts +3 -7
- package/src/echarts.ts +38 -2
- package/src/editor/highlight-api.ts +444 -0
- package/src/editor/keywords.ts +6 -19
- package/src/er/parser.ts +10 -4
- package/src/gantt/parser.ts +7 -4
- package/src/gantt/renderer.ts +3 -5
- package/src/index.ts +106 -50
- package/src/infra/parser.ts +7 -5
- package/src/infra/renderer.ts +2 -2
- package/src/kanban/parser.ts +7 -5
- package/src/kanban/renderer.ts +43 -18
- package/src/org/parser.ts +7 -4
- package/src/org/renderer.ts +40 -29
- package/src/sequence/parser.ts +11 -5
- package/src/sequence/renderer.ts +114 -45
- package/src/sitemap/parser.ts +8 -4
- package/src/sitemap/renderer.ts +137 -57
- package/src/utils/legend-svg.ts +44 -20
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +21 -1
- package/gallery/fixtures/initiative-status-full.dgmo +0 -46
- package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
- package/gallery/fixtures/initiative-status.dgmo +0 -9
- package/src/initiative-status/collapse.ts +0 -76
- package/src/initiative-status/filter.ts +0 -63
- package/src/initiative-status/layout.ts +0 -650
- package/src/initiative-status/parser.ts +0 -629
- package/src/initiative-status/renderer.ts +0 -1199
- package/src/initiative-status/types.ts +0 -57
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Boxes and Lines Diagram — Parser
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { makeDgmoError, suggest } from '../diagnostics';
|
|
6
|
+
import type { DgmoError } from '../diagnostics';
|
|
7
|
+
import type { ParsedBoxesAndLines, BLNode, BLEdge, BLGroup } from './types';
|
|
8
|
+
import {
|
|
9
|
+
matchTagBlockHeading,
|
|
10
|
+
injectDefaultTagMetadata,
|
|
11
|
+
validateTagValues,
|
|
12
|
+
stripDefaultModifier,
|
|
13
|
+
} from '../utils/tag-groups';
|
|
14
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
15
|
+
import {
|
|
16
|
+
extractColor,
|
|
17
|
+
parseFirstLine,
|
|
18
|
+
OPTION_NOCOLON_RE,
|
|
19
|
+
} from '../utils/parsing';
|
|
20
|
+
|
|
21
|
+
const MAX_GROUP_DEPTH = 1;
|
|
22
|
+
|
|
23
|
+
/** Boxes-and-lines requires explicit first line — no heuristic detection. */
|
|
24
|
+
export function looksLikeBoxesAndLines(_content: string): boolean {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Measure leading whitespace (tabs = 4 spaces) */
|
|
29
|
+
function measureIndent(line: string): number {
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const ch of line) {
|
|
32
|
+
if (ch === ' ') count++;
|
|
33
|
+
else if (ch === '\t') count += 4;
|
|
34
|
+
else break;
|
|
35
|
+
}
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse pipe metadata segment: `key: value, key2: value2`
|
|
41
|
+
* Returns resolved metadata record. Extracts `description` separately.
|
|
42
|
+
*/
|
|
43
|
+
function parsePipeMetadata(
|
|
44
|
+
segment: string,
|
|
45
|
+
aliasMap: Map<string, string>
|
|
46
|
+
): { metadata: Record<string, string>; description?: string } {
|
|
47
|
+
const metadata: Record<string, string> = {};
|
|
48
|
+
let description: string | undefined;
|
|
49
|
+
|
|
50
|
+
const items = segment.split(',');
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const trimmed = item.trim();
|
|
53
|
+
if (!trimmed) continue;
|
|
54
|
+
|
|
55
|
+
const colonIdx = trimmed.indexOf(':');
|
|
56
|
+
if (colonIdx >= 0) {
|
|
57
|
+
const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
58
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
59
|
+
if (rawKey === 'description') {
|
|
60
|
+
description = value;
|
|
61
|
+
} else {
|
|
62
|
+
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
63
|
+
metadata[resolvedKey] = value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Bare words are ignored (no status system)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { metadata, description };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert group label to internal ID */
|
|
73
|
+
function groupId(label: string): string {
|
|
74
|
+
return `__group_${label}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
78
|
+
const result: ParsedBoxesAndLines = {
|
|
79
|
+
type: 'boxes-and-lines',
|
|
80
|
+
title: null,
|
|
81
|
+
titleLineNumber: null,
|
|
82
|
+
nodes: [],
|
|
83
|
+
edges: [],
|
|
84
|
+
groups: [],
|
|
85
|
+
tagGroups: [],
|
|
86
|
+
options: {},
|
|
87
|
+
initialHiddenTagValues: new Map(),
|
|
88
|
+
direction: 'LR',
|
|
89
|
+
diagnostics: [],
|
|
90
|
+
error: null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const lines = content.split('\n');
|
|
94
|
+
const nodeLabels = new Set<string>();
|
|
95
|
+
const groupLabels = new Set<string>();
|
|
96
|
+
let lastNodeLabel: string | null = null;
|
|
97
|
+
|
|
98
|
+
// Group stack for nesting
|
|
99
|
+
interface GroupState {
|
|
100
|
+
group: BLGroup;
|
|
101
|
+
indent: number;
|
|
102
|
+
depth: number;
|
|
103
|
+
}
|
|
104
|
+
const groupStack: GroupState[] = [];
|
|
105
|
+
|
|
106
|
+
// Tag block state
|
|
107
|
+
let contentStarted = false;
|
|
108
|
+
let currentTagGroup: TagGroup | null = null;
|
|
109
|
+
const aliasMap = new Map<string, string>();
|
|
110
|
+
|
|
111
|
+
const pushWarning = (lineNumber: number, message: string) => {
|
|
112
|
+
result.diagnostics.push(makeDgmoError(lineNumber, message, 'warning'));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Get the innermost active group, if any */
|
|
116
|
+
function currentGroupState(): GroupState | null {
|
|
117
|
+
return groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Close groups that are at or deeper than a given indent level */
|
|
121
|
+
function closeGroupsToIndent(indent: number) {
|
|
122
|
+
while (
|
|
123
|
+
groupStack.length > 0 &&
|
|
124
|
+
groupStack[groupStack.length - 1].indent >= indent
|
|
125
|
+
) {
|
|
126
|
+
const gs = groupStack.pop()!;
|
|
127
|
+
result.groups.push(gs.group);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Ensure a node exists (implicit creation) */
|
|
132
|
+
function ensureNode(label: string, lineNum: number) {
|
|
133
|
+
if (!nodeLabels.has(label)) {
|
|
134
|
+
result.nodes.push({
|
|
135
|
+
label,
|
|
136
|
+
lineNumber: lineNum,
|
|
137
|
+
metadata: {},
|
|
138
|
+
});
|
|
139
|
+
nodeLabels.add(label);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
const lineNum = i + 1;
|
|
145
|
+
const raw = lines[i];
|
|
146
|
+
const trimmed = raw.trim();
|
|
147
|
+
const indent = measureIndent(raw);
|
|
148
|
+
|
|
149
|
+
// Skip blanks and comments
|
|
150
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
151
|
+
|
|
152
|
+
// First line: `boxes-and-lines [Title]`
|
|
153
|
+
const firstLineResult = parseFirstLine(trimmed);
|
|
154
|
+
if (firstLineResult && !contentStarted && i < 5) {
|
|
155
|
+
if (firstLineResult.chartType !== 'boxes-and-lines') {
|
|
156
|
+
const diag = makeDgmoError(
|
|
157
|
+
lineNum,
|
|
158
|
+
`Expected chart type "boxes-and-lines", got "${firstLineResult.chartType}"`
|
|
159
|
+
);
|
|
160
|
+
result.diagnostics.push(diag);
|
|
161
|
+
result.error = diag.message;
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
if (firstLineResult.title) {
|
|
165
|
+
result.title = firstLineResult.title;
|
|
166
|
+
result.titleLineNumber = lineNum;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Directives (non-indented, before or during content)
|
|
172
|
+
if (indent === 0) {
|
|
173
|
+
// direction TB / direction LR
|
|
174
|
+
const dirMatch = trimmed.match(/^direction\s+(TB|LR)$/i);
|
|
175
|
+
if (dirMatch) {
|
|
176
|
+
result.direction = dirMatch[1].toUpperCase() as 'LR' | 'TB';
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// hide directive: `hide team:Backend, team:Frontend`
|
|
181
|
+
const hideMatch = trimmed.match(/^hide\s+(.+)/i);
|
|
182
|
+
if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
|
|
183
|
+
const pairs = hideMatch[1].split(',');
|
|
184
|
+
for (const pair of pairs) {
|
|
185
|
+
const colonIdx = pair.indexOf(':');
|
|
186
|
+
if (colonIdx > 0) {
|
|
187
|
+
const groupKey = pair.substring(0, colonIdx).trim().toLowerCase();
|
|
188
|
+
const value = pair
|
|
189
|
+
.substring(colonIdx + 1)
|
|
190
|
+
.trim()
|
|
191
|
+
.toLowerCase();
|
|
192
|
+
if (groupKey && value) {
|
|
193
|
+
if (!result.initialHiddenTagValues.has(groupKey)) {
|
|
194
|
+
result.initialHiddenTagValues.set(groupKey, new Set());
|
|
195
|
+
}
|
|
196
|
+
result.initialHiddenTagValues.get(groupKey)!.add(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// active-tag directive
|
|
204
|
+
if (!contentStarted) {
|
|
205
|
+
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
206
|
+
if (optMatch) {
|
|
207
|
+
const key = optMatch[1].toLowerCase();
|
|
208
|
+
const value = optMatch[2].trim();
|
|
209
|
+
if (key === 'active-tag') {
|
|
210
|
+
result.options[key] = value;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Tag group heading — must be checked BEFORE group/node/edge matching
|
|
218
|
+
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
219
|
+
if (tagBlockMatch && indent === 0) {
|
|
220
|
+
if (contentStarted) {
|
|
221
|
+
result.diagnostics.push(
|
|
222
|
+
makeDgmoError(
|
|
223
|
+
lineNum,
|
|
224
|
+
'Tag groups must appear before diagram content',
|
|
225
|
+
'error'
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
currentTagGroup = {
|
|
231
|
+
name: tagBlockMatch.name,
|
|
232
|
+
alias: tagBlockMatch.alias,
|
|
233
|
+
entries: [],
|
|
234
|
+
lineNumber: lineNum,
|
|
235
|
+
};
|
|
236
|
+
if (tagBlockMatch.alias) {
|
|
237
|
+
aliasMap.set(
|
|
238
|
+
tagBlockMatch.alias.toLowerCase(),
|
|
239
|
+
tagBlockMatch.name.toLowerCase()
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (tagBlockMatch.inlineValues) {
|
|
243
|
+
for (const rawVal of tagBlockMatch.inlineValues) {
|
|
244
|
+
const { text: cleanVal, isDefault } = stripDefaultModifier(rawVal);
|
|
245
|
+
const { label, color } = extractColor(cleanVal);
|
|
246
|
+
currentTagGroup.entries.push({
|
|
247
|
+
value: label,
|
|
248
|
+
color: color ?? '',
|
|
249
|
+
lineNumber: lineNum,
|
|
250
|
+
});
|
|
251
|
+
if (isDefault) currentTagGroup.defaultValue = label;
|
|
252
|
+
}
|
|
253
|
+
if (!currentTagGroup.defaultValue && currentTagGroup.entries.length > 0) {
|
|
254
|
+
currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
result.tagGroups.push(currentTagGroup);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Tag group entries (indented under tag heading)
|
|
262
|
+
if (currentTagGroup && !contentStarted && indent > 0) {
|
|
263
|
+
const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
|
|
264
|
+
const { label, color } = extractColor(cleanEntry);
|
|
265
|
+
currentTagGroup.entries.push({
|
|
266
|
+
value: label,
|
|
267
|
+
color: color ?? '',
|
|
268
|
+
lineNumber: lineNum,
|
|
269
|
+
});
|
|
270
|
+
if (isDefault) {
|
|
271
|
+
currentTagGroup.defaultValue = label;
|
|
272
|
+
} else if (currentTagGroup.entries.length === 1) {
|
|
273
|
+
currentTagGroup.defaultValue = label;
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Non-indented line closes tag group
|
|
279
|
+
if (currentTagGroup && indent === 0) {
|
|
280
|
+
currentTagGroup = null; // eslint-disable-line no-useless-assignment
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Close groups that are no longer scoped by indent
|
|
284
|
+
if (indent === 0) {
|
|
285
|
+
closeGroupsToIndent(0);
|
|
286
|
+
} else if (groupStack.length > 0) {
|
|
287
|
+
// Close groups deeper than current indent
|
|
288
|
+
closeGroupsToIndent(indent);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Group-to-group edge: [Group A] -> [Group B] or [Group A] <-> [Group B]
|
|
292
|
+
const groupEdgeMatch = trimmed.match(
|
|
293
|
+
/^\[(.+?)\]\s*(<->|->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
|
|
294
|
+
);
|
|
295
|
+
if (groupEdgeMatch) {
|
|
296
|
+
contentStarted = true;
|
|
297
|
+
currentTagGroup = null;
|
|
298
|
+
const sourceLabel = groupEdgeMatch[1];
|
|
299
|
+
const arrow = groupEdgeMatch[2];
|
|
300
|
+
const targetLabel = groupEdgeMatch[3];
|
|
301
|
+
const metaSeg = groupEdgeMatch[4];
|
|
302
|
+
|
|
303
|
+
let edgeMeta: Record<string, string> = {};
|
|
304
|
+
if (metaSeg) {
|
|
305
|
+
const parsed = parsePipeMetadata(metaSeg, aliasMap);
|
|
306
|
+
edgeMeta = parsed.metadata;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
result.edges.push({
|
|
310
|
+
source: groupId(sourceLabel),
|
|
311
|
+
target: groupId(targetLabel),
|
|
312
|
+
label: undefined,
|
|
313
|
+
bidirectional: arrow === '<->',
|
|
314
|
+
lineNumber: lineNum,
|
|
315
|
+
metadata: edgeMeta,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Labeled group-to-group edge: [Group A] -label-> [Group B]
|
|
321
|
+
const labeledGroupEdgeMatch = trimmed.match(
|
|
322
|
+
/^\[(.+?)\]\s*(?:<-(.+)->|-(.+)->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
|
|
323
|
+
);
|
|
324
|
+
if (labeledGroupEdgeMatch) {
|
|
325
|
+
contentStarted = true;
|
|
326
|
+
currentTagGroup = null;
|
|
327
|
+
const sourceLabel = labeledGroupEdgeMatch[1];
|
|
328
|
+
const biLabel = labeledGroupEdgeMatch[2];
|
|
329
|
+
const uniLabel = labeledGroupEdgeMatch[3];
|
|
330
|
+
const targetLabel = labeledGroupEdgeMatch[4];
|
|
331
|
+
const metaSeg = labeledGroupEdgeMatch[5];
|
|
332
|
+
|
|
333
|
+
let edgeMeta: Record<string, string> = {};
|
|
334
|
+
if (metaSeg) {
|
|
335
|
+
const parsed = parsePipeMetadata(metaSeg, aliasMap);
|
|
336
|
+
edgeMeta = parsed.metadata;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
result.edges.push({
|
|
340
|
+
source: groupId(sourceLabel),
|
|
341
|
+
target: groupId(targetLabel),
|
|
342
|
+
label: (biLabel ?? uniLabel)?.trim(),
|
|
343
|
+
bidirectional: !!biLabel,
|
|
344
|
+
lineNumber: lineNum,
|
|
345
|
+
metadata: edgeMeta,
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Group header: [Group Name] or [Group Name] | metadata
|
|
351
|
+
const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
|
|
352
|
+
if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
|
|
353
|
+
contentStarted = true;
|
|
354
|
+
currentTagGroup = null;
|
|
355
|
+
const label = groupMatch[1];
|
|
356
|
+
|
|
357
|
+
// Check nesting depth
|
|
358
|
+
const currentDepth = groupStack.length + 1;
|
|
359
|
+
if (currentDepth > MAX_GROUP_DEPTH) {
|
|
360
|
+
result.diagnostics.push(
|
|
361
|
+
makeDgmoError(
|
|
362
|
+
lineNum,
|
|
363
|
+
`Group nesting exceeds maximum depth of ${MAX_GROUP_DEPTH}`,
|
|
364
|
+
'warning'
|
|
365
|
+
)
|
|
366
|
+
);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const groupMeta: Record<string, string> = {};
|
|
371
|
+
if (groupMatch[2]) {
|
|
372
|
+
const items = groupMatch[2].split(',');
|
|
373
|
+
for (const item of items) {
|
|
374
|
+
const ci = item.indexOf(':');
|
|
375
|
+
if (ci >= 0) {
|
|
376
|
+
const rawKey = item.slice(0, ci).trim().toLowerCase();
|
|
377
|
+
const value = item.slice(ci + 1).trim();
|
|
378
|
+
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
379
|
+
groupMeta[resolvedKey] = value;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const group: BLGroup = {
|
|
385
|
+
label,
|
|
386
|
+
children: [],
|
|
387
|
+
lineNumber: lineNum,
|
|
388
|
+
metadata: groupMeta,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
groupLabels.add(label);
|
|
392
|
+
groupStack.push({ group, indent, depth: currentDepth });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Edge detection: contains `->` or `<->`
|
|
397
|
+
if (trimmed.includes('->') || trimmed.includes('<->')) {
|
|
398
|
+
contentStarted = true;
|
|
399
|
+
currentTagGroup = null;
|
|
400
|
+
let edgeText = trimmed;
|
|
401
|
+
|
|
402
|
+
// Indented shorthand: `-> Target` or `-label-> Target`
|
|
403
|
+
if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
|
|
404
|
+
if (!lastNodeLabel) {
|
|
405
|
+
result.diagnostics.push(
|
|
406
|
+
makeDgmoError(
|
|
407
|
+
lineNum,
|
|
408
|
+
'Indented edge has no preceding node to use as source',
|
|
409
|
+
'warning'
|
|
410
|
+
)
|
|
411
|
+
);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
edgeText = `${lastNodeLabel} ${trimmed}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const edge = parseEdgeLine(
|
|
418
|
+
edgeText,
|
|
419
|
+
lineNum,
|
|
420
|
+
aliasMap,
|
|
421
|
+
result.diagnostics
|
|
422
|
+
);
|
|
423
|
+
if (edge) {
|
|
424
|
+
result.edges.push(edge);
|
|
425
|
+
// Add to current group if indented
|
|
426
|
+
// (edges don't become group children, but their nodes might)
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Node: everything else
|
|
432
|
+
contentStarted = true;
|
|
433
|
+
currentTagGroup = null;
|
|
434
|
+
const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
|
|
435
|
+
if (!node) {
|
|
436
|
+
result.diagnostics.push(
|
|
437
|
+
makeDgmoError(lineNum, `Unexpected line: '${trimmed}'.`, 'warning')
|
|
438
|
+
);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
lastNodeLabel = node.label;
|
|
442
|
+
|
|
443
|
+
const gs = currentGroupState();
|
|
444
|
+
const isGroupChild = gs && indent > gs.indent;
|
|
445
|
+
|
|
446
|
+
if (nodeLabels.has(node.label)) {
|
|
447
|
+
// Already declared — if inside a group, just add as child (no duplicate)
|
|
448
|
+
if (isGroupChild) {
|
|
449
|
+
gs.group.children.push(node.label);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
result.diagnostics.push(
|
|
453
|
+
makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
|
|
454
|
+
);
|
|
455
|
+
} else {
|
|
456
|
+
nodeLabels.add(node.label);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Cascade group metadata into node (group provides defaults, node overrides)
|
|
460
|
+
if (isGroupChild) {
|
|
461
|
+
for (const [key, val] of Object.entries(gs.group.metadata)) {
|
|
462
|
+
if (!(key in node.metadata)) {
|
|
463
|
+
node.metadata[key] = val;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
gs.group.children.push(node.label);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
result.nodes.push(node);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Close any remaining groups
|
|
473
|
+
while (groupStack.length > 0) {
|
|
474
|
+
const gs = groupStack.pop()!;
|
|
475
|
+
result.groups.push(gs.group);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Implicit node creation for edge endpoints
|
|
479
|
+
for (const edge of result.edges) {
|
|
480
|
+
// Skip group references
|
|
481
|
+
if (!edge.source.startsWith('__group_')) {
|
|
482
|
+
ensureNode(edge.source, edge.lineNumber);
|
|
483
|
+
}
|
|
484
|
+
if (!edge.target.startsWith('__group_')) {
|
|
485
|
+
ensureNode(edge.target, edge.lineNumber);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Post-parse: inject default tag metadata and validate tag values
|
|
490
|
+
if (result.tagGroups.length > 0) {
|
|
491
|
+
injectDefaultTagMetadata(result.nodes, result.tagGroups);
|
|
492
|
+
validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ============================================================
|
|
499
|
+
// Line parsers
|
|
500
|
+
// ============================================================
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Parse a node line. Supports:
|
|
504
|
+
* - `Label`
|
|
505
|
+
* - `Label | key: value, key2: value2`
|
|
506
|
+
*/
|
|
507
|
+
function parseNodeLine(
|
|
508
|
+
trimmed: string,
|
|
509
|
+
lineNum: number,
|
|
510
|
+
aliasMap: Map<string, string>,
|
|
511
|
+
_diagnostics: DgmoError[]
|
|
512
|
+
): BLNode | null {
|
|
513
|
+
let metadata: Record<string, string> = {};
|
|
514
|
+
let description: string | undefined;
|
|
515
|
+
|
|
516
|
+
// Split on pipe for metadata
|
|
517
|
+
const pipeIdx = trimmed.indexOf('|');
|
|
518
|
+
let label: string;
|
|
519
|
+
|
|
520
|
+
if (pipeIdx >= 0) {
|
|
521
|
+
label = trimmed.slice(0, pipeIdx).trim();
|
|
522
|
+
const metaSegment = trimmed.slice(pipeIdx + 1).trim();
|
|
523
|
+
const parsed = parsePipeMetadata(metaSegment, aliasMap);
|
|
524
|
+
metadata = parsed.metadata;
|
|
525
|
+
description = parsed.description;
|
|
526
|
+
} else {
|
|
527
|
+
label = trimmed;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!label) return null;
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
label,
|
|
534
|
+
lineNumber: lineNum,
|
|
535
|
+
metadata,
|
|
536
|
+
description,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Parse an edge line. Supports:
|
|
542
|
+
* - `Source -> Target`
|
|
543
|
+
* - `Source -> Target | key: value`
|
|
544
|
+
* - `Source -label-> Target`
|
|
545
|
+
* - `Source <-> Target`
|
|
546
|
+
* - `Source <-label-> Target`
|
|
547
|
+
* - `Source -label-> Target | key: value`
|
|
548
|
+
*/
|
|
549
|
+
function parseEdgeLine(
|
|
550
|
+
trimmed: string,
|
|
551
|
+
lineNum: number,
|
|
552
|
+
aliasMap: Map<string, string>,
|
|
553
|
+
diagnostics: DgmoError[]
|
|
554
|
+
): BLEdge | null {
|
|
555
|
+
// Check for bidirectional labeled: `Source <-label-> Target`
|
|
556
|
+
const biLabeledMatch = trimmed.match(/^(.+?)\s*<-(.+)->\s*(.+)$/);
|
|
557
|
+
if (biLabeledMatch) {
|
|
558
|
+
const source = biLabeledMatch[1].trim();
|
|
559
|
+
const label = biLabeledMatch[2].trim();
|
|
560
|
+
let rest = biLabeledMatch[3].trim();
|
|
561
|
+
|
|
562
|
+
let metadata: Record<string, string> = {};
|
|
563
|
+
const pipeIdx = rest.indexOf('|');
|
|
564
|
+
if (pipeIdx >= 0) {
|
|
565
|
+
const parsed = parsePipeMetadata(
|
|
566
|
+
rest.slice(pipeIdx + 1).trim(),
|
|
567
|
+
aliasMap
|
|
568
|
+
);
|
|
569
|
+
metadata = parsed.metadata;
|
|
570
|
+
rest = rest.slice(0, pipeIdx).trim();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!source || !rest) {
|
|
574
|
+
diagnostics.push(
|
|
575
|
+
makeDgmoError(lineNum, 'Edge is missing source or target')
|
|
576
|
+
);
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
source,
|
|
582
|
+
target: rest,
|
|
583
|
+
label: label || undefined,
|
|
584
|
+
bidirectional: true,
|
|
585
|
+
lineNumber: lineNum,
|
|
586
|
+
metadata,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Check for bidirectional plain: `Source <-> Target`
|
|
591
|
+
const biIdx = trimmed.indexOf('<->');
|
|
592
|
+
if (biIdx >= 0) {
|
|
593
|
+
const source = trimmed.slice(0, biIdx).trim();
|
|
594
|
+
let rest = trimmed.slice(biIdx + 3).trim();
|
|
595
|
+
|
|
596
|
+
let metadata: Record<string, string> = {};
|
|
597
|
+
const pipeIdx = rest.indexOf('|');
|
|
598
|
+
if (pipeIdx >= 0) {
|
|
599
|
+
const parsed = parsePipeMetadata(
|
|
600
|
+
rest.slice(pipeIdx + 1).trim(),
|
|
601
|
+
aliasMap
|
|
602
|
+
);
|
|
603
|
+
metadata = parsed.metadata;
|
|
604
|
+
rest = rest.slice(0, pipeIdx).trim();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!source || !rest) {
|
|
608
|
+
diagnostics.push(
|
|
609
|
+
makeDgmoError(lineNum, 'Edge is missing source or target')
|
|
610
|
+
);
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
source,
|
|
616
|
+
target: rest,
|
|
617
|
+
bidirectional: true,
|
|
618
|
+
lineNumber: lineNum,
|
|
619
|
+
metadata,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Check for labeled arrow: `Source -label-> Target`
|
|
624
|
+
const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
|
|
625
|
+
if (labeledMatch) {
|
|
626
|
+
const source = labeledMatch[1].trim();
|
|
627
|
+
const label = labeledMatch[2].trim();
|
|
628
|
+
let rest = labeledMatch[3].trim();
|
|
629
|
+
|
|
630
|
+
if (label) {
|
|
631
|
+
let metadata: Record<string, string> = {};
|
|
632
|
+
const pipeIdx = rest.indexOf('|');
|
|
633
|
+
if (pipeIdx >= 0) {
|
|
634
|
+
const parsed = parsePipeMetadata(
|
|
635
|
+
rest.slice(pipeIdx + 1).trim(),
|
|
636
|
+
aliasMap
|
|
637
|
+
);
|
|
638
|
+
metadata = parsed.metadata;
|
|
639
|
+
rest = rest.slice(0, pipeIdx).trim();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (!source || !rest) {
|
|
643
|
+
diagnostics.push(
|
|
644
|
+
makeDgmoError(lineNum, 'Edge is missing source or target')
|
|
645
|
+
);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
source,
|
|
651
|
+
target: rest,
|
|
652
|
+
label,
|
|
653
|
+
bidirectional: false,
|
|
654
|
+
lineNumber: lineNum,
|
|
655
|
+
metadata,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Plain arrow: `Source -> Target`
|
|
661
|
+
const arrowIdx = trimmed.indexOf('->');
|
|
662
|
+
if (arrowIdx < 0) return null;
|
|
663
|
+
|
|
664
|
+
const source = trimmed.slice(0, arrowIdx).trim();
|
|
665
|
+
let rest = trimmed.slice(arrowIdx + 2).trim();
|
|
666
|
+
|
|
667
|
+
if (!source || !rest) {
|
|
668
|
+
diagnostics.push(
|
|
669
|
+
makeDgmoError(lineNum, 'Edge is missing source or target')
|
|
670
|
+
);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let metadata: Record<string, string> = {};
|
|
675
|
+
const pipeIdx = rest.indexOf('|');
|
|
676
|
+
if (pipeIdx >= 0) {
|
|
677
|
+
const parsed = parsePipeMetadata(rest.slice(pipeIdx + 1).trim(), aliasMap);
|
|
678
|
+
metadata = parsed.metadata;
|
|
679
|
+
rest = rest.slice(0, pipeIdx).trim();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!rest) {
|
|
683
|
+
diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
source,
|
|
689
|
+
target: rest,
|
|
690
|
+
bidirectional: false,
|
|
691
|
+
lineNumber: lineNum,
|
|
692
|
+
metadata,
|
|
693
|
+
};
|
|
694
|
+
}
|