@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.
- package/README.md +18 -0
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +4 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +403 -38
- package/dist/parser/index.js.map +1 -1
- package/dist/printer/index.d.ts.map +1 -1
- package/dist/printer/index.js +177 -104
- package/dist/printer/index.js.map +1 -1
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +13 -6
- package/dist/strings.js.map +1 -1
- package/dist/structured.d.ts +5 -0
- package/dist/structured.d.ts.map +1 -0
- package/dist/structured.js +67 -0
- package/dist/structured.js.map +1 -0
- package/package.json +1 -1
package/dist/parser/index.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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.
|
|
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.
|
|
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: {
|
|
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`,
|
|
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:
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
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',
|
|
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}"`,
|
|
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 [
|
|
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}
|
|
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
|
|
273
|
-
const
|
|
274
|
-
const
|
|
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`,
|
|
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
|
*
|