@coral-viz/language 0.2.3 → 0.2.4

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.
@@ -5,19 +5,29 @@
5
5
  */
6
6
  import { IdAllocator, allocateEdgeId, labelToId } from '../ids.js';
7
7
  import { unescapeString } from '../strings.js';
8
+ import { isJsonSafe, isPlainObject } from '../structured.js';
8
9
  import { finishParse, parseDiagnostic, } from '../diagnostics.js';
9
10
  /**
10
11
  * Parse Coral DSL text into Graph-IR
11
12
  */
12
13
  export function parse(source, options = {}) {
13
14
  const { includeSourceInfo = false, graphId = 'coral-graph', graphName, } = options;
15
+ const sourceLines = source.split('\n');
16
+ const lineStarts = [0];
17
+ for (let index = 0; index < source.length; index++) {
18
+ if (source[index] === '\n')
19
+ lineStarts.push(index + 1);
20
+ }
14
21
  // Create context for parsing
15
22
  const ctx = {
16
23
  source,
24
+ sourceLines,
25
+ lineStarts,
17
26
  includeSourceInfo,
18
27
  nodeIds: new IdAllocator(),
19
28
  edgeIds: new IdAllocator(),
20
29
  diagnostics: [],
30
+ sawDeclaration: false,
21
31
  };
22
32
  // The portable TypeScript parser is the supported 0.2 runtime. The
23
33
  // Tree-sitter grammar is shipped and tested separately for editor tooling.
@@ -26,18 +36,115 @@ export function parse(source, options = {}) {
26
36
  // everything that parsed (R-009 AC-8).
27
37
  const graph = {
28
38
  version: '1.0.0',
29
- id: graphId,
39
+ id: ctx.graphHeader?.id ?? graphId,
30
40
  nodes: parseResult.nodes,
31
41
  edges: parseResult.edges,
32
42
  };
33
- if (graphName) {
34
- graph.name = graphName;
35
- }
43
+ const resolvedName = ctx.graphHeader?.name ?? graphName;
44
+ if (resolvedName !== undefined)
45
+ graph.name = resolvedName;
46
+ if (ctx.graphHeader?.metadata !== undefined)
47
+ graph.metadata = ctx.graphHeader.metadata;
48
+ if (ctx.graphHeader?.layoutOptions !== undefined)
49
+ graph.layoutOptions = ctx.graphHeader.layoutOptions;
36
50
  return finishParse(ctx.diagnostics, graph);
37
51
  }
38
52
  /** Record a diagnostic on the context. */
39
- function report(ctx, severity, code, message, position, source) {
40
- ctx.diagnostics.push(parseDiagnostic({ severity, code, message, position, source }));
53
+ function report(ctx, severity, code, message, position, source, rangeSource = source ?? '') {
54
+ const rawLine = ctx.sourceLines[position.line - 1] ?? '';
55
+ const lineStart = ctx.lineStarts[position.line - 1] ?? 0;
56
+ const normalizedPosition = {
57
+ line: position.line,
58
+ column: Math.min(Math.max(0, position.column), rawLine.length),
59
+ offset: lineStart + Math.min(Math.max(0, position.column), rawLine.length),
60
+ };
61
+ const matches = [];
62
+ if (rangeSource && !rangeSource.includes('\n')) {
63
+ for (let from = 0; from <= rawLine.length - rangeSource.length;) {
64
+ const found = rawLine.indexOf(rangeSource, from);
65
+ if (found < 0)
66
+ break;
67
+ matches.push(found);
68
+ from = found + Math.max(1, rangeSource.length);
69
+ }
70
+ }
71
+ const exactColumn = matches.includes(normalizedPosition.column)
72
+ ? normalizedPosition.column
73
+ : matches.length === 1 ? matches[0] : undefined;
74
+ const start = exactColumn === undefined ? normalizedPosition : {
75
+ line: position.line,
76
+ column: exactColumn,
77
+ offset: lineStart + exactColumn,
78
+ };
79
+ ctx.diagnostics.push(parseDiagnostic({
80
+ severity,
81
+ code,
82
+ message,
83
+ position: start,
84
+ source,
85
+ ...(exactColumn !== undefined ? { range: {
86
+ start,
87
+ end: {
88
+ line: position.line,
89
+ column: exactColumn + rangeSource.length,
90
+ offset: lineStart + exactColumn + rangeSource.length,
91
+ },
92
+ } } : {}),
93
+ }));
94
+ }
95
+ function tokenPosition(ctx, lineNumber, token, after, keyToken = false) {
96
+ const rawLine = ctx.sourceLines[lineNumber - 1] ?? '';
97
+ const anchor = after ? syntaxAnchor(rawLine, after) : -1;
98
+ let found = rawLine.indexOf(token, anchor >= 0 ? anchor + (after?.length ?? 0) : 0);
99
+ if (keyToken) {
100
+ const candidates = [];
101
+ while (found >= 0) {
102
+ let cursor = found + token.length;
103
+ while (/\s/.test(rawLine[cursor] ?? ''))
104
+ cursor++;
105
+ if (rawLine[cursor] === ':')
106
+ candidates.push(found);
107
+ found = rawLine.indexOf(token, found + token.length);
108
+ }
109
+ found = candidates.length === 1 ? candidates[0] : -1;
110
+ }
111
+ const column = Math.max(0, found);
112
+ const lineStart = ctx.lineStarts[lineNumber - 1] ?? 0;
113
+ return { line: lineNumber, column, offset: lineStart + column };
114
+ }
115
+ function syntaxAnchor(line, needle) {
116
+ let quoted = false;
117
+ let escaped = false;
118
+ for (let index = 0; index <= line.length - needle.length; index++) {
119
+ const char = line[index];
120
+ if (escaped) {
121
+ escaped = false;
122
+ continue;
123
+ }
124
+ if (quoted && char === '\\') {
125
+ escaped = true;
126
+ continue;
127
+ }
128
+ if (char === '"') {
129
+ quoted = !quoted;
130
+ continue;
131
+ }
132
+ if (!quoted && line.startsWith(needle, index))
133
+ return index;
134
+ }
135
+ return -1;
136
+ }
137
+ function exactLineRange(ctx, line, lineStart, lineNumber) {
138
+ const rawLineEnd = ctx.source.indexOf('\n', lineStart);
139
+ const rawLine = ctx.source.slice(lineStart, rawLineEnd < 0 ? ctx.source.length : rawLineEnd);
140
+ const column = rawLine.indexOf(line);
141
+ const startColumn = column < 0 ? 0 : column;
142
+ return {
143
+ start: lineStart + startColumn,
144
+ end: lineStart + startColumn + line.length,
145
+ startPosition: { line: lineNumber, column: startColumn },
146
+ endPosition: { line: lineNumber, column: startColumn + line.length },
147
+ };
41
148
  }
42
149
  /**
43
150
  * Pure JS parser implementation
@@ -46,7 +153,7 @@ function report(ctx, severity, code, message, position, source) {
46
153
  function parseSource(ctx) {
47
154
  const nodes = [];
48
155
  const edges = [];
49
- const lines = ctx.source.split('\n');
156
+ const lines = ctx.sourceLines;
50
157
  let currentOffset = 0;
51
158
  let lineNumber = 0;
52
159
  // Track brace depth for parsing bodies
@@ -59,6 +166,11 @@ function parseSource(ctx) {
59
166
  currentOffset += line.length + 1;
60
167
  continue;
61
168
  }
169
+ if (trimmed.startsWith('@graph')) {
170
+ parseGraphDirective(trimmed, ctx, currentOffset, lineNumber);
171
+ currentOffset += line.length + 1;
172
+ continue;
173
+ }
62
174
  // Handle closing braces
63
175
  if (trimmed === '}') {
64
176
  if (braceStack.length === 0) {
@@ -71,6 +183,7 @@ function parseSource(ctx) {
71
183
  // Try to parse node declaration
72
184
  const nodeMatch = parseNodeDeclaration(trimmed, ctx, currentOffset, lineNumber);
73
185
  if (nodeMatch) {
186
+ ctx.sawDeclaration = true;
74
187
  if (braceStack.length > 0) {
75
188
  // This is a child node
76
189
  const parent = braceStack[braceStack.length - 1].node;
@@ -88,7 +201,11 @@ function parseSource(ctx) {
88
201
  braceStack.push({
89
202
  node: nodeMatch,
90
203
  indent: line.search(/\S/),
91
- opened: { line: lineNumber, column: Math.max(line.indexOf('{'), 0), offset: currentOffset },
204
+ opened: {
205
+ line: lineNumber,
206
+ column: Math.max(line.lastIndexOf('{'), 0),
207
+ offset: currentOffset + Math.max(line.lastIndexOf('{'), 0),
208
+ },
92
209
  });
93
210
  }
94
211
  currentOffset += line.length + 1;
@@ -102,7 +219,7 @@ function parseSource(ctx) {
102
219
  parent.properties = {};
103
220
  }
104
221
  if (propertyMatch.key.startsWith('_')) {
105
- report(ctx, 'info', 'coral.property.reserved-key.dropped', `Property key "${propertyMatch.key}" is reserved for internal use and is not stored`, { line: lineNumber, column: 0, offset: currentOffset }, trimmed);
222
+ report(ctx, 'info', 'coral.property.reserved-key.dropped', `Property key "${propertyMatch.key}" is reserved for internal use and is not stored`, tokenPosition(ctx, lineNumber, propertyMatch.key), trimmed, propertyMatch.key);
106
223
  }
107
224
  else {
108
225
  parent.properties[propertyMatch.key] = propertyMatch.value;
@@ -113,6 +230,7 @@ function parseSource(ctx) {
113
230
  // Try to parse edge declaration
114
231
  const edgeMatch = parseEdgeDeclaration(trimmed, ctx, currentOffset, lineNumber);
115
232
  if (edgeMatch) {
233
+ ctx.sawDeclaration = true;
116
234
  edges.push(edgeMatch);
117
235
  currentOffset += line.length + 1;
118
236
  continue;
@@ -123,7 +241,7 @@ function parseSource(ctx) {
123
241
  }
124
242
  // Check for unclosed braces
125
243
  for (const unclosed of braceStack) {
126
- report(ctx, 'error', 'coral.brace.unparsed', 'Unclosed brace', unclosed.opened);
244
+ report(ctx, 'error', 'coral.brace.unparsed', 'Unclosed brace', unclosed.opened, '{', '{');
127
245
  }
128
246
  return {
129
247
  success: !ctx.diagnostics.some((d) => d.severity === 'error'),
@@ -131,27 +249,88 @@ function parseSource(ctx) {
131
249
  edges,
132
250
  };
133
251
  }
252
+ function parseGraphDirective(line, ctx, offset, lineNumber) {
253
+ if (ctx.sawDeclaration || ctx.graphHeader) {
254
+ report(ctx, 'error', 'coral.graph.unparsed', 'Graph header must appear once before declarations', tokenPosition(ctx, lineNumber, '@graph'), line, '@graph');
255
+ return;
256
+ }
257
+ const raw = line.slice('@graph'.length).trim();
258
+ const value = parseJsonObject(raw);
259
+ if (!value || typeof value.id !== 'string' || value.id.length === 0) {
260
+ report(ctx, 'error', 'coral.graph.unparsed', 'Graph header must be a JSON object with a non-empty string id', tokenPosition(ctx, lineNumber, raw || '@graph', raw ? '@graph' : undefined), line, raw || '@graph');
261
+ return;
262
+ }
263
+ const header = { id: value.id };
264
+ const allowed = new Set(['id', 'name', 'metadata', 'layoutOptions']);
265
+ for (const key of Object.keys(value)) {
266
+ if (!allowed.has(key))
267
+ reportDataField(ctx, 'graph', key, 'is not supported', offset, lineNumber, line);
268
+ }
269
+ if (value.name !== undefined) {
270
+ if (typeof value.name === 'string')
271
+ header.name = value.name;
272
+ else
273
+ reportDataField(ctx, 'graph', 'name', 'must be a string', offset, lineNumber, line);
274
+ }
275
+ if (value.metadata !== undefined) {
276
+ if (isGraphMetadata(value.metadata))
277
+ header.metadata = value.metadata;
278
+ else
279
+ reportDataField(ctx, 'graph', 'metadata', 'has the wrong shape', offset, lineNumber, line);
280
+ }
281
+ if (value.layoutOptions !== undefined) {
282
+ if (isLayoutOptions(value.layoutOptions))
283
+ header.layoutOptions = value.layoutOptions;
284
+ else
285
+ reportDataField(ctx, 'graph', 'layoutOptions', 'has the wrong shape', offset, lineNumber, line);
286
+ }
287
+ ctx.graphHeader = header;
288
+ }
289
+ function parseJsonObject(raw) {
290
+ try {
291
+ const value = JSON.parse(raw);
292
+ return isPlainObject(value) ? value : null;
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ function reportDataField(ctx, kind, field, reason, _offset, lineNumber, source) {
299
+ const fieldParts = field.split('.');
300
+ const leafField = fieldParts[fieldParts.length - 1] ?? field;
301
+ const quotedField = `"${leafField}"`;
302
+ const rangeSource = source.includes(quotedField) ? quotedField : leafField;
303
+ const anchor = source.includes(' data ') ? ' data ' : source.startsWith('@graph') ? '@graph' : undefined;
304
+ report(ctx, 'warning', `coral.${kind}.data-field.unsupported`, `${field} ${reason}`, tokenPosition(ctx, lineNumber, rangeSource, anchor, true), source, rangeSource);
305
+ }
134
306
  function parseNodeDeclaration(line, ctx, offset, lineNumber) {
135
- // Match: node_type "label" [as id] [{ ...]
307
+ // Match: type label [as id] [@ (x,y)] [pinned] [data object] [{ ...]
136
308
  const NUM = '-?\\d+(?:\\.\\d+)?';
137
- const nodeRegex = new RegExp('^([A-Za-z_][\\w-]*)\\s+"((?:[^"\\\\]|\\\\.)*)"' +
309
+ const nodeRegex = new RegExp('^(?:"((?:[^"\\\\]|\\\\.)*)"|([A-Za-z_][\\w-]*))\\s+"((?:[^"\\\\]|\\\\.)*)"' +
138
310
  '(?:\\s+as\\s+(?:"((?:[^"\\\\]|\\\\.)*)"|([A-Za-z_][A-Za-z0-9_]*)))?' +
139
311
  `(?:\\s*@\\s*\\(\\s*(${NUM})\\s*,\\s*(${NUM})\\s*\\))?` +
140
312
  '(\\s+pinned)?' +
313
+ '(?:\\s+data\\s+(.+?))?' +
141
314
  '(\\s*\\{\\s*\\}|\\s*\\{)?$');
142
315
  const match = line.match(nodeRegex);
143
316
  if (!match) {
144
317
  return null;
145
318
  }
146
- const nodeType = match[1];
147
- const label = unescapeString(match[2]);
148
- const explicitId = match[3] !== undefined ? unescapeString(match[3]) : match[4];
149
- const posX = match[5];
150
- const posY = match[6];
151
- const isPinned = match[7] !== undefined;
319
+ const nodeType = match[1] !== undefined ? unescapeString(match[1]) : match[2];
320
+ if (nodeType.length === 0) {
321
+ report(ctx, 'error', 'coral.node.type.unparsed', 'Node type must not be empty', tokenPosition(ctx, lineNumber, '""'), line, '""');
322
+ return null;
323
+ }
324
+ const label = unescapeString(match[3]);
325
+ const explicitId = match[4] !== undefined ? unescapeString(match[4]) : match[5];
326
+ const explicitIdToken = match[4] !== undefined ? `"${match[4]}"` : match[5];
327
+ const posX = match[6];
328
+ const posY = match[7];
329
+ const isPinned = match[8] !== undefined;
330
+ const rawData = match[9];
152
331
  let id;
153
332
  if (explicitId === '') {
154
- report(ctx, 'warning', 'coral.node.id.degraded', 'An empty id clause cannot be honoured; the id was derived from the label instead', { line: lineNumber, column: 0, offset }, line);
333
+ report(ctx, 'warning', 'coral.node.id.degraded', 'An empty id clause cannot be honoured; the id was derived from the label instead', tokenPosition(ctx, lineNumber, explicitIdToken ?? '""', ' as '), line, explicitIdToken ?? '""');
155
334
  id = ctx.nodeIds.allocateFromLabel(label);
156
335
  }
157
336
  else if (explicitId !== undefined) {
@@ -161,7 +340,7 @@ function parseNodeDeclaration(line, ctx, offset, lineNumber) {
161
340
  else {
162
341
  // The id is already spoken for, so the reservation cannot be honoured.
163
342
  id = ctx.nodeIds.allocateFromLabel(label);
164
- report(ctx, 'warning', 'coral.node.id.degraded', `Node id "${explicitId}" is already in use; this declaration was given "${id}"`, { line: lineNumber, column: 0, offset }, line);
343
+ report(ctx, 'warning', 'coral.node.id.degraded', `Node id "${explicitId}" is already in use; this declaration was given "${id}"`, tokenPosition(ctx, lineNumber, explicitIdToken, ' as '), line, explicitIdToken);
165
344
  }
166
345
  }
167
346
  else {
@@ -179,18 +358,116 @@ function parseNodeDeclaration(line, ctx, offset, lineNumber) {
179
358
  if (isPinned) {
180
359
  node.pinned = true;
181
360
  }
361
+ if (rawData !== undefined)
362
+ applyNodeData(node, rawData.trim(), ctx, offset, lineNumber, line);
182
363
  if (ctx.includeSourceInfo) {
183
364
  node.sourceInfo = {
184
- range: {
185
- start: offset,
186
- end: offset + line.length,
187
- startPosition: { line: lineNumber, column: 0 },
188
- endPosition: { line: lineNumber, column: line.length },
189
- },
365
+ range: exactLineRange(ctx, line, offset, lineNumber),
190
366
  };
191
367
  }
192
368
  return node;
193
369
  }
370
+ function applyNodeData(node, raw, ctx, offset, lineNumber, source) {
371
+ const data = parseJsonObject(raw);
372
+ if (!data) {
373
+ report(ctx, 'error', 'coral.node.data.unparsed', 'Node data must be a JSON object', tokenPosition(ctx, lineNumber, raw, ' data '), source, raw);
374
+ return;
375
+ }
376
+ const allowed = new Set(['description', 'dimensions', 'layoutOptions', 'ports', 'symbol', 'variant']);
377
+ for (const [key, value] of Object.entries(data)) {
378
+ if (!allowed.has(key)) {
379
+ reportDataField(ctx, 'node', key, 'is not supported', offset, lineNumber, source);
380
+ continue;
381
+ }
382
+ const valid = ((key === 'description' || key === 'symbol' || key === 'variant') && typeof value === 'string') ||
383
+ (key === 'dimensions' && isDimensions(value)) ||
384
+ (key === 'layoutOptions' && isNodeLayoutOptions(value)) ||
385
+ (key === 'ports' && isPorts(value));
386
+ if (!valid) {
387
+ reportDataField(ctx, 'node', key, 'has the wrong shape', offset, lineNumber, source);
388
+ continue;
389
+ }
390
+ node[key] = value;
391
+ }
392
+ }
393
+ function isDimensions(value) {
394
+ return isPlainObject(value) && typeof value.width === 'number' && Number.isFinite(value.width) &&
395
+ value.width >= 0 && typeof value.height === 'number' && Number.isFinite(value.height) && value.height >= 0 &&
396
+ hasOnlyKeys(value, ['width', 'height']);
397
+ }
398
+ function hasOnlyKeys(value, allowed) {
399
+ return Object.keys(value).every((key) => allowed.includes(key));
400
+ }
401
+ function optionalEnum(value, choices) {
402
+ return value === undefined || (typeof value === 'string' && choices.includes(value));
403
+ }
404
+ function optionalNonNegative(value) {
405
+ return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
406
+ }
407
+ function isGraphMetadata(value) {
408
+ if (!isPlainObject(value) || !hasOnlyKeys(value, ['author', 'created', 'modified', 'description', 'tags', 'notation', 'custom']))
409
+ return false;
410
+ for (const key of ['author', 'created', 'modified', 'description', 'notation']) {
411
+ if (value[key] !== undefined && typeof value[key] !== 'string')
412
+ return false;
413
+ }
414
+ if (value.tags !== undefined && (!Array.isArray(value.tags) || !value.tags.every((tag) => typeof tag === 'string')))
415
+ return false;
416
+ return value.custom === undefined || (isPlainObject(value.custom) && isJsonSafe(value.custom));
417
+ }
418
+ function isLayoutOptions(value) {
419
+ if (!isPlainObject(value) || !hasOnlyKeys(value, ['algorithm', 'direction', 'spacing', 'edgeRouting', 'hierarchyHandling', 'algorithmOptions']))
420
+ return false;
421
+ if (!optionalEnum(value.algorithm, ['layered', 'force', 'radial', 'tree', 'fixed']))
422
+ return false;
423
+ if (!optionalEnum(value.direction, ['DOWN', 'UP', 'LEFT', 'RIGHT']))
424
+ return false;
425
+ if (!optionalEnum(value.edgeRouting, ['orthogonal', 'polyline', 'spline']))
426
+ return false;
427
+ if (!optionalEnum(value.hierarchyHandling, ['INCLUDE_CHILDREN', 'SEPARATE_CHILDREN']))
428
+ return false;
429
+ if (value.spacing !== undefined) {
430
+ if (!isPlainObject(value.spacing) || !hasOnlyKeys(value.spacing, ['nodeNode', 'nodeEdge', 'edgeEdge', 'layerSpacing']))
431
+ return false;
432
+ const spacing = value.spacing;
433
+ if (!['nodeNode', 'nodeEdge', 'edgeEdge', 'layerSpacing'].every((key) => optionalNonNegative(spacing[key])))
434
+ return false;
435
+ }
436
+ if (value.algorithmOptions !== undefined) {
437
+ if (!isPlainObject(value.algorithmOptions) || !Object.values(value.algorithmOptions).every((item) => typeof item === 'string' || typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item))))
438
+ return false;
439
+ }
440
+ return true;
441
+ }
442
+ function isNodeLayoutOptions(value) {
443
+ if (!isPlainObject(value) || !hasOnlyKeys(value, ['portConstraints', 'portAlignment', 'sizeConstraints', 'minimumSize', 'padding']))
444
+ return false;
445
+ if (!optionalEnum(value.portConstraints, ['FREE', 'FIXED_SIDE', 'FIXED_ORDER', 'FIXED_POS']))
446
+ return false;
447
+ if (!optionalEnum(value.portAlignment, ['BEGIN', 'CENTER', 'END', 'JUSTIFIED']))
448
+ return false;
449
+ if (value.sizeConstraints !== undefined && (!Array.isArray(value.sizeConstraints) || !value.sizeConstraints.every((item) => typeof item === 'string' && ['MINIMUM_SIZE', 'NODE_LABELS', 'PORTS'].includes(item))))
450
+ return false;
451
+ if (value.minimumSize !== undefined && !isDimensions(value.minimumSize))
452
+ return false;
453
+ if (value.padding !== undefined) {
454
+ if (!isPlainObject(value.padding) || !hasOnlyKeys(value.padding, ['top', 'right', 'bottom', 'left']))
455
+ return false;
456
+ const padding = value.padding;
457
+ if (!['top', 'right', 'bottom', 'left'].every((key) => optionalNonNegative(padding[key])))
458
+ return false;
459
+ }
460
+ return true;
461
+ }
462
+ function isPorts(value) {
463
+ const sides = new Set(['NORTH', 'SOUTH', 'EAST', 'WEST']);
464
+ return Array.isArray(value) && value.every((port) => isPlainObject(port) &&
465
+ hasOnlyKeys(port, ['id', 'side', 'label', 'position', 'properties']) &&
466
+ typeof port.id === 'string' && port.id.length > 0 && typeof port.side === 'string' && sides.has(port.side) &&
467
+ (port.label === undefined || typeof port.label === 'string') &&
468
+ (port.position === undefined || (typeof port.position === 'number' && Number.isFinite(port.position) && port.position >= 0 && port.position <= 1)) &&
469
+ (port.properties === undefined || (isPlainObject(port.properties) && isJsonSafe(port.properties))));
470
+ }
194
471
  function parseProperty(line) {
195
472
  // Match: key: "value" (string), key: 123 (number), key: true/false (boolean)
196
473
  const keyMatch = line.match(/^(?:"((?:[^"\\]|\\.)*)"|([a-zA-Z_][a-zA-Z0-9_]*))\s*:\s*/);
@@ -250,6 +527,18 @@ function parseProperty(line) {
250
527
  return { key, value: true };
251
528
  if (rest === 'false')
252
529
  return { key, value: false };
530
+ if (rest === 'null')
531
+ return { key, value: null };
532
+ if (rest.startsWith('[') || rest.startsWith('{')) {
533
+ try {
534
+ const value = JSON.parse(rest);
535
+ if (isJsonSafe(value))
536
+ return { key, value };
537
+ }
538
+ catch {
539
+ // Fall through to the ordinary invalid-property path.
540
+ }
541
+ }
253
542
  // Try number. The pattern must accept everything String(n) can emit for a
254
543
  // finite number, including exponent form such as 1e-7 and 1e+21.
255
544
  if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(rest)) {
@@ -260,18 +549,34 @@ function parseProperty(line) {
260
549
  return null;
261
550
  }
262
551
  function parseEdgeDeclaration(line, ctx, offset, lineNumber) {
263
- // Match: source -> target [type, attr = "value"]
552
+ // Match: source -> target [as id] [attributes] [data object] [via points]
264
553
  const ENDPOINT = '(?:"((?:[^"\\\\]|\\\\.)*)"|([A-Za-z_][A-Za-z0-9_]*))';
265
- const edgeRegex = new RegExp(`^${ENDPOINT}\\s*->\\s*${ENDPOINT}(?:\\s*\\[(.+?)\\])?((?:\\s+via(?:\\s*\\(\\s*-?\\d+(?:\\.\\d+)?\\s*,\\s*-?\\d+(?:\\.\\d+)?\\s*\\))+)?)$`);
554
+ const edgeRegex = new RegExp(`^${ENDPOINT}\\s*->\\s*${ENDPOINT}` +
555
+ '(?:\\s+as\\s+(?:"((?:[^"\\\\]|\\\\.)*)"|([A-Za-z_][A-Za-z0-9_]*)))?' +
556
+ '(?:\\s*\\[(.+?)\\])?' +
557
+ '(?:\\s+data\\s+(.+?))?' +
558
+ `((?:\\s+via(?:\\s*\\(\\s*-?\\d+(?:\\.\\d+)?\\s*,\\s*-?\\d+(?:\\.\\d+)?\\s*\\))+)?)$`);
266
559
  const match = line.match(edgeRegex);
267
560
  if (!match) {
268
561
  return null;
269
562
  }
270
563
  const source = match[1] !== undefined ? unescapeString(match[1]) : match[2];
271
564
  const target = match[3] !== undefined ? unescapeString(match[3]) : match[4];
272
- const attributes = match[5];
273
- const waypointClause = match[6];
274
- const edgeId = allocateEdgeId(source, target, ctx.edgeIds);
565
+ const explicitId = match[5] !== undefined ? unescapeString(match[5]) : match[6];
566
+ const explicitIdToken = match[5] !== undefined ? `"${match[5]}"` : match[6];
567
+ const attributes = match[7];
568
+ const rawData = match[8];
569
+ const waypointClause = match[9];
570
+ let edgeId;
571
+ if (explicitId !== undefined && ctx.edgeIds.reserve(explicitId)) {
572
+ edgeId = explicitId;
573
+ }
574
+ else {
575
+ edgeId = allocateEdgeId(source, target, ctx.edgeIds);
576
+ if (explicitId !== undefined) {
577
+ report(ctx, 'warning', 'coral.edge.id.degraded', explicitId === '' ? `An empty edge id was replaced with "${edgeId}"` : `Edge id "${explicitId}" is already in use; this edge was given "${edgeId}"`, tokenPosition(ctx, lineNumber, explicitIdToken, ' as '), line, explicitIdToken);
578
+ }
579
+ }
275
580
  const edge = {
276
581
  id: edgeId,
277
582
  source,
@@ -302,7 +607,7 @@ function parseEdgeDeclaration(line, ctx, offset, lineNumber) {
302
607
  }
303
608
  // Reserved for internal use; dropped on both sides (R-009 AC-7).
304
609
  if (attr.key.startsWith('_')) {
305
- report(ctx, 'info', 'coral.edge.reserved-key.dropped', `Attribute key "${attr.key}" is reserved for internal use and is not stored`, { line: lineNumber, column: 0, offset }, line);
610
+ report(ctx, 'info', 'coral.edge.reserved-key.dropped', `Attribute key "${attr.key}" is reserved for internal use and is not stored`, tokenPosition(ctx, lineNumber, attr.key, '['), line, attr.key);
306
611
  continue;
307
612
  }
308
613
  if (!edge.properties)
@@ -310,6 +615,8 @@ function parseEdgeDeclaration(line, ctx, offset, lineNumber) {
310
615
  edge.properties[attr.key] = attr.value;
311
616
  }
312
617
  }
618
+ if (rawData !== undefined)
619
+ applyEdgeData(edge, rawData.trim(), ctx, offset, lineNumber, line);
313
620
  // Waypoints follow the attributes (R-018).
314
621
  if (waypointClause) {
315
622
  const points = [];
@@ -324,16 +631,74 @@ function parseEdgeDeclaration(line, ctx, offset, lineNumber) {
324
631
  }
325
632
  if (ctx.includeSourceInfo) {
326
633
  edge.sourceInfo = {
327
- range: {
328
- start: offset,
329
- end: offset + line.length,
330
- startPosition: { line: lineNumber, column: 0 },
331
- endPosition: { line: lineNumber, column: line.length },
332
- },
634
+ range: exactLineRange(ctx, line, offset, lineNumber),
333
635
  };
334
636
  }
335
637
  return edge;
336
638
  }
639
+ function applyEdgeData(edge, raw, ctx, offset, lineNumber, source) {
640
+ const data = parseJsonObject(raw);
641
+ if (!data) {
642
+ report(ctx, 'error', 'coral.edge.data.unparsed', 'Edge data must be a JSON object', tokenPosition(ctx, lineNumber, raw, ' data '), source, raw);
643
+ return;
644
+ }
645
+ const allowed = new Set(['sourcePort', 'targetPort', 'style', 'properties']);
646
+ for (const [key, value] of Object.entries(data)) {
647
+ if (!allowed.has(key)) {
648
+ reportDataField(ctx, 'edge', key, 'is not supported', offset, lineNumber, source);
649
+ continue;
650
+ }
651
+ if (key === 'sourcePort' || key === 'targetPort') {
652
+ if (typeof value === 'string')
653
+ edge[key] = value;
654
+ else
655
+ reportDataField(ctx, 'edge', key, 'must be a string', offset, lineNumber, source);
656
+ continue;
657
+ }
658
+ if (key === 'style') {
659
+ if (!isPlainObject(value)) {
660
+ reportDataField(ctx, 'edge', key, 'must be an object', offset, lineNumber, source);
661
+ continue;
662
+ }
663
+ const style = {};
664
+ const enums = {
665
+ sourceArrow: new Set(['arrow', 'diamond', 'circle', 'none']),
666
+ targetArrow: new Set(['arrow', 'diamond', 'circle', 'none']),
667
+ routing: new Set(['orthogonal', 'polyline', 'spline']),
668
+ };
669
+ for (const [styleKey, styleValue] of Object.entries(value)) {
670
+ if (styleKey === 'lineStyle' || !enums[styleKey]?.has(String(styleValue))) {
671
+ reportDataField(ctx, 'edge', `style.${styleKey}`, 'is not supported here', offset, lineNumber, source);
672
+ }
673
+ else {
674
+ style[styleKey] = styleValue;
675
+ }
676
+ }
677
+ if (Object.keys(style).length > 0)
678
+ edge.style = { ...edge.style, ...style };
679
+ continue;
680
+ }
681
+ if (!isPlainObject(value) || !isJsonSafe(value)) {
682
+ reportDataField(ctx, 'edge', 'properties', 'must be a JSON object', offset, lineNumber, source);
683
+ continue;
684
+ }
685
+ if (!edge.properties)
686
+ edge.properties = {};
687
+ for (const [propertyKey, propertyValue] of Object.entries(value)) {
688
+ if (propertyKey.startsWith('_')) {
689
+ const keyToken = source.includes(`"${propertyKey}"`) ? `"${propertyKey}"` : propertyKey;
690
+ report(ctx, 'info', 'coral.edge.reserved-key.dropped', `Attribute key "${propertyKey}" is reserved for internal use and is not stored`, tokenPosition(ctx, lineNumber, keyToken, ' data '), source, keyToken);
691
+ }
692
+ else if (Object.prototype.hasOwnProperty.call(edge.properties, propertyKey)) {
693
+ const keyToken = source.includes(`"${propertyKey}"`) ? `"${propertyKey}"` : propertyKey;
694
+ report(ctx, 'warning', 'coral.edge.property.degraded', `Structured property "${propertyKey}" conflicts with a readable attribute and was ignored`, tokenPosition(ctx, lineNumber, keyToken, ' data '), source, keyToken);
695
+ }
696
+ else {
697
+ edge.properties[propertyKey] = propertyValue;
698
+ }
699
+ }
700
+ }
701
+ }
337
702
  /**
338
703
  * Scan an edge attribute list.
339
704
  *