@diagrammo/dgmo 0.2.18 → 0.2.20
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/README.md +33 -33
- package/dist/cli.cjs +148 -144
- package/dist/index.cjs +8773 -8072
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -1
- package/dist/index.d.ts +55 -1
- package/dist/index.js +8727 -8028
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chart.ts +40 -9
- package/src/class/parser.ts +37 -6
- package/src/class/renderer.ts +11 -8
- package/src/class/types.ts +4 -0
- package/src/cli.ts +38 -3
- package/src/d3.ts +115 -47
- package/src/dgmo-mermaid.ts +7 -1
- package/src/dgmo-router.ts +67 -4
- package/src/diagnostics.ts +77 -0
- package/src/echarts.ts +23 -14
- package/src/er/layout.ts +49 -7
- package/src/er/parser.ts +31 -4
- package/src/er/renderer.ts +2 -1
- package/src/er/types.ts +3 -0
- package/src/graph/flowchart-parser.ts +34 -4
- package/src/graph/flowchart-renderer.ts +35 -32
- package/src/graph/types.ts +4 -0
- package/src/index.ts +11 -0
- package/src/org/layout.ts +46 -21
- package/src/org/parser.ts +35 -14
- package/src/org/renderer.ts +25 -12
- package/src/org/resolver.ts +470 -0
- package/src/sequence/parser.ts +90 -33
- package/src/sequence/renderer.ts +6 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import type { DgmoError } from '../diagnostics';
|
|
2
|
+
import { makeDgmoError } from '../diagnostics';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Async or sync file reader. Receives an absolute path, returns content.
|
|
10
|
+
* Throwing means "file not found".
|
|
11
|
+
*/
|
|
12
|
+
export type ReadFileFn = (path: string) => string | Promise<string>;
|
|
13
|
+
|
|
14
|
+
export interface ResolveImportsResult {
|
|
15
|
+
content: string;
|
|
16
|
+
diagnostics: DgmoError[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// Constants
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
const MAX_DEPTH = 10;
|
|
24
|
+
const IMPORT_RE = /^(\s+)import:\s+(.+\.dgmo)\s*$/i;
|
|
25
|
+
const TAGS_RE = /^tags:\s+(.+\.dgmo)\s*$/i;
|
|
26
|
+
const HEADER_RE = /^(chart|title)\s*:/i;
|
|
27
|
+
const OPTION_RE = /^[a-z][a-z0-9-]*\s*:/i;
|
|
28
|
+
const GROUP_HEADING_RE = /^##\s+/;
|
|
29
|
+
|
|
30
|
+
// ============================================================
|
|
31
|
+
// Path Helpers (pure string ops — no Node `path` dependency)
|
|
32
|
+
// ============================================================
|
|
33
|
+
|
|
34
|
+
function dirname(filePath: string): string {
|
|
35
|
+
const last = filePath.lastIndexOf('/');
|
|
36
|
+
return last > 0 ? filePath.substring(0, last) : '/';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolvePath(base: string, relative: string): string {
|
|
40
|
+
const parts = dirname(base).split('/');
|
|
41
|
+
for (const seg of relative.split('/')) {
|
|
42
|
+
if (seg === '..') {
|
|
43
|
+
if (parts.length > 1) parts.pop();
|
|
44
|
+
} else if (seg !== '.' && seg !== '') {
|
|
45
|
+
parts.push(seg);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts.join('/') || '/';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================
|
|
52
|
+
// Tag Group Extraction
|
|
53
|
+
// ============================================================
|
|
54
|
+
|
|
55
|
+
interface TagGroupBlock {
|
|
56
|
+
name: string; // lowercased for comparison
|
|
57
|
+
lines: string[]; // raw lines including heading + entries
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract ## tag group blocks from content lines.
|
|
62
|
+
* Returns blocks in order, each with its heading + indented entries.
|
|
63
|
+
*/
|
|
64
|
+
function extractTagGroups(lines: string[]): TagGroupBlock[] {
|
|
65
|
+
const blocks: TagGroupBlock[] = [];
|
|
66
|
+
let current: TagGroupBlock | null = null;
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (GROUP_HEADING_RE.test(trimmed)) {
|
|
71
|
+
// Extract group name (everything after "## " up to optional alias/color)
|
|
72
|
+
const nameMatch = trimmed.match(/^##\s+(.+?)(?:\s+alias\s+\w+)?(?:\s*\([^)]+\))?\s*$/);
|
|
73
|
+
const name = nameMatch ? nameMatch[1].trim().toLowerCase() : trimmed.substring(3).trim().toLowerCase();
|
|
74
|
+
current = { name, lines: [line] };
|
|
75
|
+
blocks.push(current);
|
|
76
|
+
} else if (current) {
|
|
77
|
+
if (trimmed === '' || trimmed.startsWith('//')) {
|
|
78
|
+
// Blank line or comment ends the tag group
|
|
79
|
+
current = null;
|
|
80
|
+
} else if (line.match(/^\s+/)) {
|
|
81
|
+
// Indented = tag entry
|
|
82
|
+
current.lines.push(line);
|
|
83
|
+
} else {
|
|
84
|
+
// Non-indented non-heading = end of tag group
|
|
85
|
+
current = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return blocks;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================
|
|
94
|
+
// Header Stripping
|
|
95
|
+
// ============================================================
|
|
96
|
+
|
|
97
|
+
interface ParsedHeader {
|
|
98
|
+
/** Lines that are NOT header/tags/tag-groups — the "content" body */
|
|
99
|
+
contentLines: string[];
|
|
100
|
+
tagGroups: TagGroupBlock[];
|
|
101
|
+
tagsDirective: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Separate an imported file into header (stripped) and content body.
|
|
106
|
+
* Also extracts tag groups and tags: directive for merging.
|
|
107
|
+
*/
|
|
108
|
+
function parseFileHeader(lines: string[]): ParsedHeader {
|
|
109
|
+
const tagGroups = extractTagGroups(lines);
|
|
110
|
+
const tagGroupLineSet = new Set<number>();
|
|
111
|
+
for (const group of tagGroups) {
|
|
112
|
+
// Find where this group starts in lines
|
|
113
|
+
for (let i = 0; i < lines.length; i++) {
|
|
114
|
+
if (lines[i] === group.lines[0]) {
|
|
115
|
+
for (let j = 0; j < group.lines.length; j++) {
|
|
116
|
+
tagGroupLineSet.add(i + j);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let tagsDirective: string | null = null;
|
|
124
|
+
const contentLines: string[] = [];
|
|
125
|
+
let headerDone = false;
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
// Skip tag group lines
|
|
129
|
+
if (tagGroupLineSet.has(i)) continue;
|
|
130
|
+
|
|
131
|
+
const trimmed = lines[i].trim();
|
|
132
|
+
|
|
133
|
+
// Skip blank/comment lines in header region
|
|
134
|
+
if (!headerDone && (trimmed === '' || trimmed.startsWith('//'))) continue;
|
|
135
|
+
|
|
136
|
+
// Header lines
|
|
137
|
+
if (!headerDone) {
|
|
138
|
+
if (HEADER_RE.test(trimmed)) continue;
|
|
139
|
+
|
|
140
|
+
const tagsMatch = trimmed.match(TAGS_RE);
|
|
141
|
+
if (tagsMatch) {
|
|
142
|
+
tagsDirective = tagsMatch[1].trim();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Other option-like header lines (non-indented key: value)
|
|
147
|
+
if (OPTION_RE.test(trimmed) && !trimmed.startsWith('##') && !lines[i].match(/^\s/)) {
|
|
148
|
+
// Check it's not a content line (node with metadata)
|
|
149
|
+
const key = trimmed.split(':')[0].trim().toLowerCase();
|
|
150
|
+
if (key !== 'chart' && key !== 'title' && !trimmed.includes('|')) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
headerDone = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
contentLines.push(lines[i]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { contentLines, tagGroups, tagsDirective };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================
|
|
165
|
+
// Main Resolver
|
|
166
|
+
// ============================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pre-processes org chart content, resolving `tags:` and `import:` directives.
|
|
170
|
+
*
|
|
171
|
+
* @param content - Raw .dgmo file content
|
|
172
|
+
* @param filePath - Absolute path of the file (for relative path resolution)
|
|
173
|
+
* @param readFileFn - Function to read files (sync or async)
|
|
174
|
+
* @returns Merged content with all imports resolved + diagnostics
|
|
175
|
+
*/
|
|
176
|
+
export async function resolveOrgImports(
|
|
177
|
+
content: string,
|
|
178
|
+
filePath: string,
|
|
179
|
+
readFileFn: ReadFileFn,
|
|
180
|
+
): Promise<ResolveImportsResult> {
|
|
181
|
+
const diagnostics: DgmoError[] = [];
|
|
182
|
+
const result = await resolveFile(content, filePath, readFileFn, diagnostics, new Set([filePath]), 0);
|
|
183
|
+
return { content: result, diagnostics };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function resolveFile(
|
|
187
|
+
content: string,
|
|
188
|
+
filePath: string,
|
|
189
|
+
readFileFn: ReadFileFn,
|
|
190
|
+
diagnostics: DgmoError[],
|
|
191
|
+
ancestorChain: Set<string>,
|
|
192
|
+
depth: number,
|
|
193
|
+
): Promise<string> {
|
|
194
|
+
const lines = content.split('\n');
|
|
195
|
+
|
|
196
|
+
// ---- Step 1: Identify header, tags directive, inline tag groups ----
|
|
197
|
+
const headerLines: string[] = [];
|
|
198
|
+
let tagsDirective: string | null = null;
|
|
199
|
+
const inlineTagGroups = extractTagGroups(lines);
|
|
200
|
+
const bodyStartIndex = findBodyStart(lines);
|
|
201
|
+
|
|
202
|
+
// Collect header lines (chart:, title:, options, tags:)
|
|
203
|
+
for (let i = 0; i < bodyStartIndex; i++) {
|
|
204
|
+
const trimmed = lines[i].trim();
|
|
205
|
+
if (trimmed === '' || trimmed.startsWith('//')) {
|
|
206
|
+
headerLines.push(lines[i]);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (GROUP_HEADING_RE.test(trimmed)) continue; // skip inline tag group headings
|
|
210
|
+
if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
|
|
211
|
+
|
|
212
|
+
const tagsMatch = trimmed.match(TAGS_RE);
|
|
213
|
+
if (tagsMatch) {
|
|
214
|
+
tagsDirective = tagsMatch[1].trim();
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
headerLines.push(lines[i]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Step 2: Resolve tags: directive ----
|
|
222
|
+
let tagsFileGroups: TagGroupBlock[] = [];
|
|
223
|
+
if (tagsDirective) {
|
|
224
|
+
const tagsPath = resolvePath(filePath, tagsDirective);
|
|
225
|
+
try {
|
|
226
|
+
const tagsContent = await readFileFn(tagsPath);
|
|
227
|
+
const tagsLines = tagsContent.split('\n');
|
|
228
|
+
tagsFileGroups = extractTagGroups(tagsLines);
|
|
229
|
+
} catch {
|
|
230
|
+
diagnostics.push(
|
|
231
|
+
makeDgmoError(0, `Tags file not found: ${tagsDirective}`)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- Step 3: Resolve import: directives in body ----
|
|
237
|
+
const bodyLines = lines.slice(bodyStartIndex);
|
|
238
|
+
const resolvedBodyLines: string[] = [];
|
|
239
|
+
const importedTagGroups: TagGroupBlock[] = [];
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
242
|
+
const line = bodyLines[i];
|
|
243
|
+
const lineNumber = bodyStartIndex + i + 1; // 1-based for diagnostics
|
|
244
|
+
const importMatch = line.match(IMPORT_RE);
|
|
245
|
+
|
|
246
|
+
if (!importMatch) {
|
|
247
|
+
// Pass through — skip inline tag group lines (already extracted above)
|
|
248
|
+
const trimmed = line.trim();
|
|
249
|
+
if (GROUP_HEADING_RE.test(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
resolvedBodyLines.push(line);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const indent = importMatch[1];
|
|
257
|
+
const importRelPath = importMatch[2].trim();
|
|
258
|
+
const importAbsPath = resolvePath(filePath, importRelPath);
|
|
259
|
+
|
|
260
|
+
// Depth check
|
|
261
|
+
if (depth >= MAX_DEPTH) {
|
|
262
|
+
diagnostics.push(
|
|
263
|
+
makeDgmoError(lineNumber, `Import depth limit exceeded (${MAX_DEPTH}): ${importRelPath}`)
|
|
264
|
+
);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Circular check
|
|
269
|
+
if (ancestorChain.has(importAbsPath)) {
|
|
270
|
+
const chain = [...ancestorChain, importAbsPath].map(p => p.split('/').pop()).join(' -> ');
|
|
271
|
+
diagnostics.push(
|
|
272
|
+
makeDgmoError(lineNumber, `Circular import detected: ${chain}`)
|
|
273
|
+
);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Read imported file
|
|
278
|
+
let importedContent: string;
|
|
279
|
+
try {
|
|
280
|
+
importedContent = await readFileFn(importAbsPath);
|
|
281
|
+
} catch {
|
|
282
|
+
diagnostics.push(
|
|
283
|
+
makeDgmoError(lineNumber, `Import file not found: ${importRelPath}`)
|
|
284
|
+
);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Recurse to resolve nested imports
|
|
289
|
+
const nestedChain = new Set(ancestorChain);
|
|
290
|
+
nestedChain.add(importAbsPath);
|
|
291
|
+
const resolved = await resolveFile(
|
|
292
|
+
importedContent,
|
|
293
|
+
importAbsPath,
|
|
294
|
+
readFileFn,
|
|
295
|
+
diagnostics,
|
|
296
|
+
nestedChain,
|
|
297
|
+
depth + 1,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Strip header, extract tag groups from resolved content
|
|
301
|
+
const resolvedLines = resolved.split('\n');
|
|
302
|
+
const parsed = parseFileHeader(resolvedLines);
|
|
303
|
+
|
|
304
|
+
// Collect tag groups from imported file (lowest priority)
|
|
305
|
+
for (const group of parsed.tagGroups) {
|
|
306
|
+
importedTagGroups.push(group);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Re-indent and insert content lines
|
|
310
|
+
const importedContentLines = parsed.contentLines.filter(
|
|
311
|
+
(l) => l.trim() !== '' // skip trailing blank lines
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Trim trailing empty lines but keep internal structure
|
|
315
|
+
let lastNonEmpty = importedContentLines.length - 1;
|
|
316
|
+
while (lastNonEmpty >= 0 && importedContentLines[lastNonEmpty].trim() === '') {
|
|
317
|
+
lastNonEmpty--;
|
|
318
|
+
}
|
|
319
|
+
const trimmedImported = importedContentLines.slice(0, lastNonEmpty + 1);
|
|
320
|
+
|
|
321
|
+
for (const importedLine of trimmedImported) {
|
|
322
|
+
if (importedLine.trim() === '') {
|
|
323
|
+
resolvedBodyLines.push('');
|
|
324
|
+
} else {
|
|
325
|
+
resolvedBodyLines.push(indent + importedLine);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- Step 4: Merge tag groups with precedence ----
|
|
331
|
+
// Priority: inline > tags file > imported files
|
|
332
|
+
const mergedGroups = mergeTagGroups(inlineTagGroups, tagsFileGroups, importedTagGroups);
|
|
333
|
+
|
|
334
|
+
// ---- Step 5: Rebuild output ----
|
|
335
|
+
const outputLines: string[] = [];
|
|
336
|
+
|
|
337
|
+
// Header lines (chart:, title:, options — no tags: or tag groups)
|
|
338
|
+
for (const line of headerLines) {
|
|
339
|
+
outputLines.push(line);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Merged tag groups
|
|
343
|
+
if (mergedGroups.length > 0) {
|
|
344
|
+
// Ensure blank line before tag groups if header has content
|
|
345
|
+
if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
|
|
346
|
+
outputLines.push('');
|
|
347
|
+
}
|
|
348
|
+
for (const group of mergedGroups) {
|
|
349
|
+
for (const line of group.lines) {
|
|
350
|
+
outputLines.push(line);
|
|
351
|
+
}
|
|
352
|
+
outputLines.push(''); // blank line between groups
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Body content
|
|
357
|
+
// Ensure blank line separator
|
|
358
|
+
if (resolvedBodyLines.length > 0 && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
|
|
359
|
+
outputLines.push('');
|
|
360
|
+
}
|
|
361
|
+
for (const line of resolvedBodyLines) {
|
|
362
|
+
outputLines.push(line);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return outputLines.join('\n');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Helpers
|
|
370
|
+
// ============================================================
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Find the index where the body (non-header, non-tag-group content) starts.
|
|
374
|
+
*/
|
|
375
|
+
function findBodyStart(lines: string[]): number {
|
|
376
|
+
let inTagGroup = false;
|
|
377
|
+
|
|
378
|
+
for (let i = 0; i < lines.length; i++) {
|
|
379
|
+
const trimmed = lines[i].trim();
|
|
380
|
+
|
|
381
|
+
if (trimmed === '' || trimmed.startsWith('//')) {
|
|
382
|
+
if (inTagGroup) inTagGroup = false;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Tag group heading
|
|
387
|
+
if (GROUP_HEADING_RE.test(trimmed)) {
|
|
388
|
+
inTagGroup = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Tag group entry (indented under heading)
|
|
393
|
+
if (inTagGroup && lines[i].match(/^\s+/)) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (inTagGroup) {
|
|
398
|
+
inTagGroup = false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Header directives
|
|
402
|
+
if (HEADER_RE.test(trimmed)) continue;
|
|
403
|
+
if (TAGS_RE.test(trimmed)) continue;
|
|
404
|
+
|
|
405
|
+
// Option-like lines (non-indented key: value before content)
|
|
406
|
+
if (OPTION_RE.test(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
|
|
407
|
+
const key = trimmed.split(':')[0].trim().toLowerCase();
|
|
408
|
+
if (key !== 'chart' && key !== 'title') {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// This is the first body line
|
|
414
|
+
return i;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return lines.length;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Check if a line is a tag group entry (indented line under a ## heading).
|
|
422
|
+
*/
|
|
423
|
+
function isTagGroupEntry(line: string, allLines: string[], index: number): boolean {
|
|
424
|
+
if (!line.match(/^\s+/)) return false;
|
|
425
|
+
// Walk backwards to find the nearest non-blank, non-comment, non-entry line
|
|
426
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
427
|
+
const prev = allLines[i].trim();
|
|
428
|
+
if (prev === '' || prev.startsWith('//')) continue;
|
|
429
|
+
if (GROUP_HEADING_RE.test(prev)) return true;
|
|
430
|
+
if (allLines[i].match(/^\s+/)) continue; // another entry
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Merge tag groups from three sources with priority:
|
|
438
|
+
* inline (highest) > tags file > imported files (lowest).
|
|
439
|
+
*
|
|
440
|
+
* On name conflict (case-insensitive), higher priority wins.
|
|
441
|
+
* New groups from lower priority are added.
|
|
442
|
+
*/
|
|
443
|
+
function mergeTagGroups(
|
|
444
|
+
inline: TagGroupBlock[],
|
|
445
|
+
tagsFile: TagGroupBlock[],
|
|
446
|
+
imported: TagGroupBlock[],
|
|
447
|
+
): TagGroupBlock[] {
|
|
448
|
+
const seen = new Map<string, TagGroupBlock>();
|
|
449
|
+
|
|
450
|
+
// Inline first (highest priority)
|
|
451
|
+
for (const group of inline) {
|
|
452
|
+
seen.set(group.name, group);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Tags file (medium priority — only add if not overridden)
|
|
456
|
+
for (const group of tagsFile) {
|
|
457
|
+
if (!seen.has(group.name)) {
|
|
458
|
+
seen.set(group.name, group);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Imported files (lowest priority — only add if not present)
|
|
463
|
+
for (const group of imported) {
|
|
464
|
+
if (!seen.has(group.name)) {
|
|
465
|
+
seen.set(group.name, group);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return Array.from(seen.values());
|
|
470
|
+
}
|
package/src/sequence/parser.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
5
|
import { inferParticipantType } from './participant-inference';
|
|
6
|
+
import type { DgmoError } from '../diagnostics';
|
|
7
|
+
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Participant types that can be declared via "Name is a type" syntax.
|
|
@@ -140,6 +142,7 @@ export interface ParsedSequenceDgmo {
|
|
|
140
142
|
groups: SequenceGroup[];
|
|
141
143
|
sections: SequenceSection[];
|
|
142
144
|
options: Record<string, string>;
|
|
145
|
+
diagnostics: DgmoError[];
|
|
143
146
|
error: string | null;
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -235,12 +238,26 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
235
238
|
groups: [],
|
|
236
239
|
sections: [],
|
|
237
240
|
options: {},
|
|
241
|
+
diagnostics: [],
|
|
238
242
|
error: null,
|
|
239
243
|
};
|
|
240
244
|
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
const fail = (line: number, message: string): ParsedSequenceDgmo => {
|
|
246
|
+
const diag = makeDgmoError(line, message);
|
|
247
|
+
result.diagnostics.push(diag);
|
|
248
|
+
result.error = formatDgmoError(diag);
|
|
243
249
|
return result;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Push a recoverable error and continue parsing. */
|
|
253
|
+
const pushError = (line: number, message: string): void => {
|
|
254
|
+
const diag = makeDgmoError(line, message);
|
|
255
|
+
result.diagnostics.push(diag);
|
|
256
|
+
if (!result.error) result.error = formatDgmoError(diag);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (!content || !content.trim()) {
|
|
260
|
+
return fail(0, 'Empty content');
|
|
244
261
|
}
|
|
245
262
|
|
|
246
263
|
const lines = content.split('\n');
|
|
@@ -286,8 +303,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
286
303
|
if (groupMatch) {
|
|
287
304
|
const groupColor = groupMatch[2]?.trim();
|
|
288
305
|
if (groupColor && groupColor.startsWith('#')) {
|
|
289
|
-
|
|
290
|
-
|
|
306
|
+
pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
|
|
307
|
+
continue;
|
|
291
308
|
}
|
|
292
309
|
contentStarted = true;
|
|
293
310
|
activeGroup = {
|
|
@@ -310,8 +327,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
310
327
|
|
|
311
328
|
// Reject # as comment syntax (## is for group headings, handled above)
|
|
312
329
|
if (trimmed.startsWith('#') && !trimmed.startsWith('##')) {
|
|
313
|
-
|
|
314
|
-
|
|
330
|
+
pushError(lineNumber, 'Use // for comments. # is reserved for group headings (##)');
|
|
331
|
+
continue;
|
|
315
332
|
}
|
|
316
333
|
|
|
317
334
|
// Parse section dividers — "== Label ==" or "== Label(color) =="
|
|
@@ -327,8 +344,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
327
344
|
const labelRaw = sectionMatch[1].trim();
|
|
328
345
|
const colorMatch = labelRaw.match(/^(.+?)\(([^)]+)\)$/);
|
|
329
346
|
if (colorMatch && colorMatch[2].trim().startsWith('#')) {
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
|
|
348
|
+
continue;
|
|
332
349
|
}
|
|
333
350
|
contentStarted = true;
|
|
334
351
|
const section: SequenceSection = {
|
|
@@ -355,16 +372,15 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
355
372
|
if (key === 'chart') {
|
|
356
373
|
hasExplicitChart = true;
|
|
357
374
|
if (value.toLowerCase() !== 'sequence') {
|
|
358
|
-
|
|
359
|
-
return result;
|
|
375
|
+
return fail(lineNumber, `Expected chart type "sequence", got "${value}"`);
|
|
360
376
|
}
|
|
361
377
|
continue;
|
|
362
378
|
}
|
|
363
379
|
|
|
364
380
|
// Enforce headers-before-content
|
|
365
381
|
if (contentStarted) {
|
|
366
|
-
|
|
367
|
-
|
|
382
|
+
pushError(lineNumber, `Options like '${key}: ${value}' must appear before the first message or declaration`);
|
|
383
|
+
continue;
|
|
368
384
|
}
|
|
369
385
|
|
|
370
386
|
if (key === 'title') {
|
|
@@ -415,11 +431,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
415
431
|
if (activeGroup && !activeGroup.participantIds.includes(id)) {
|
|
416
432
|
const existingGroup = participantGroupMap.get(id);
|
|
417
433
|
if (existingGroup) {
|
|
418
|
-
|
|
419
|
-
|
|
434
|
+
pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
|
|
435
|
+
} else {
|
|
436
|
+
activeGroup.participantIds.push(id);
|
|
437
|
+
participantGroupMap.set(id, activeGroup.name);
|
|
420
438
|
}
|
|
421
|
-
activeGroup.participantIds.push(id);
|
|
422
|
-
participantGroupMap.set(id, activeGroup.name);
|
|
423
439
|
}
|
|
424
440
|
continue;
|
|
425
441
|
}
|
|
@@ -444,11 +460,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
444
460
|
if (activeGroup && !activeGroup.participantIds.includes(id)) {
|
|
445
461
|
const existingGroup = participantGroupMap.get(id);
|
|
446
462
|
if (existingGroup) {
|
|
447
|
-
|
|
448
|
-
|
|
463
|
+
pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
|
|
464
|
+
} else {
|
|
465
|
+
activeGroup.participantIds.push(id);
|
|
466
|
+
participantGroupMap.set(id, activeGroup.name);
|
|
449
467
|
}
|
|
450
|
-
activeGroup.participantIds.push(id);
|
|
451
|
-
participantGroupMap.set(id, activeGroup.name);
|
|
452
468
|
}
|
|
453
469
|
continue;
|
|
454
470
|
}
|
|
@@ -468,11 +484,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
468
484
|
if (!activeGroup.participantIds.includes(id)) {
|
|
469
485
|
const existingGroup = participantGroupMap.get(id);
|
|
470
486
|
if (existingGroup) {
|
|
471
|
-
|
|
472
|
-
|
|
487
|
+
pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
|
|
488
|
+
} else {
|
|
489
|
+
activeGroup.participantIds.push(id);
|
|
490
|
+
participantGroupMap.set(id, activeGroup.name);
|
|
473
491
|
}
|
|
474
|
-
activeGroup.participantIds.push(id);
|
|
475
|
-
participantGroupMap.set(id, activeGroup.name);
|
|
476
492
|
}
|
|
477
493
|
continue;
|
|
478
494
|
}
|
|
@@ -499,8 +515,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
499
515
|
// Reject "async" keyword prefix — use ~> instead
|
|
500
516
|
const asyncPrefixMatch = trimmed.match(/^async\s+(.+)$/i);
|
|
501
517
|
if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1])) {
|
|
502
|
-
|
|
503
|
-
|
|
518
|
+
pushError(lineNumber, 'Use ~> for async messages: A ~> B: message');
|
|
519
|
+
continue;
|
|
504
520
|
}
|
|
505
521
|
|
|
506
522
|
// Match ~> (async arrow) or -> (sync arrow)
|
|
@@ -614,8 +630,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
614
630
|
if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
|
|
615
631
|
const top = blockStack[blockStack.length - 1];
|
|
616
632
|
if (top.block.type === 'parallel') {
|
|
617
|
-
|
|
618
|
-
|
|
633
|
+
pushError(lineNumber, "parallel blocks don't support else if — list all concurrent messages directly inside the block");
|
|
634
|
+
continue;
|
|
619
635
|
}
|
|
620
636
|
if (top.block.type === 'if') {
|
|
621
637
|
const branch: ElseIfBranch = { label: elseIfMatch[1].trim(), children: [] };
|
|
@@ -633,8 +649,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
633
649
|
if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
|
|
634
650
|
const top = blockStack[blockStack.length - 1];
|
|
635
651
|
if (top.block.type === 'parallel') {
|
|
636
|
-
|
|
637
|
-
|
|
652
|
+
pushError(lineNumber, "parallel blocks don't support else — list all concurrent messages directly inside the block");
|
|
653
|
+
continue;
|
|
638
654
|
}
|
|
639
655
|
if (top.block.type === 'if') {
|
|
640
656
|
top.inElse = true;
|
|
@@ -712,9 +728,50 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
712
728
|
// Check if raw content has arrow patterns for inference
|
|
713
729
|
const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));
|
|
714
730
|
if (!hasArrows) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
731
|
+
return fail(1, 'No "chart: sequence" header and no sequence content detected');
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const warn = (line: number, message: string): void => {
|
|
736
|
+
result.diagnostics.push(makeDgmoError(line, message, 'warning'));
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// Warn about unused participants (only when the diagram has messages)
|
|
740
|
+
if (result.messages.length > 0) {
|
|
741
|
+
const usedIds = new Set<string>();
|
|
742
|
+
for (const msg of result.messages) {
|
|
743
|
+
usedIds.add(msg.from);
|
|
744
|
+
usedIds.add(msg.to);
|
|
745
|
+
}
|
|
746
|
+
// Walk elements recursively to find note participant references
|
|
747
|
+
const walkElements = (elements: SequenceElement[]): void => {
|
|
748
|
+
for (const el of elements) {
|
|
749
|
+
if (isSequenceNote(el)) {
|
|
750
|
+
usedIds.add(el.participantId);
|
|
751
|
+
} else if (isSequenceBlock(el)) {
|
|
752
|
+
walkElements(el.children);
|
|
753
|
+
walkElements(el.elseChildren);
|
|
754
|
+
if (el.elseIfBranches) {
|
|
755
|
+
for (const branch of el.elseIfBranches) {
|
|
756
|
+
walkElements(branch.children);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
walkElements(result.elements);
|
|
763
|
+
|
|
764
|
+
for (const p of result.participants) {
|
|
765
|
+
if (!usedIds.has(p.id)) {
|
|
766
|
+
warn(p.lineNumber, `Participant "${p.label}" is declared but never used in any message or note`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Warn about empty groups
|
|
772
|
+
for (const group of result.groups) {
|
|
773
|
+
if (group.participantIds.length === 0) {
|
|
774
|
+
warn(group.lineNumber, `Group "${group.name}" has no participants`);
|
|
718
775
|
}
|
|
719
776
|
}
|
|
720
777
|
|