@loaders.gl/graphs 4.5.3

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.
Files changed (54) hide show
  1. package/LICENSE +41 -0
  2. package/README.md +66 -0
  3. package/dist/dot-loader-types.d.ts +16 -0
  4. package/dist/dot-loader-types.d.ts.map +1 -0
  5. package/dist/dot-loader-types.js +20 -0
  6. package/dist/dot-loader-types.js.map +1 -0
  7. package/dist/dot-loader.cjs +732 -0
  8. package/dist/dot-loader.cjs.map +7 -0
  9. package/dist/dot-loader.d.ts +19 -0
  10. package/dist/dot-loader.d.ts.map +1 -0
  11. package/dist/dot-loader.js +13 -0
  12. package/dist/dot-loader.js.map +1 -0
  13. package/dist/dot-types.d.ts +32 -0
  14. package/dist/dot-types.d.ts.map +1 -0
  15. package/dist/dot-types.js +5 -0
  16. package/dist/dot-types.js.map +1 -0
  17. package/dist/graph-types.d.ts +28 -0
  18. package/dist/graph-types.d.ts.map +1 -0
  19. package/dist/graph-types.js +5 -0
  20. package/dist/graph-types.js.map +1 -0
  21. package/dist/graphml-loader-types.d.ts +16 -0
  22. package/dist/graphml-loader-types.d.ts.map +1 -0
  23. package/dist/graphml-loader-types.js +20 -0
  24. package/dist/graphml-loader-types.js.map +1 -0
  25. package/dist/graphml-loader.cjs +315 -0
  26. package/dist/graphml-loader.cjs.map +7 -0
  27. package/dist/graphml-loader.d.ts +19 -0
  28. package/dist/graphml-loader.d.ts.map +1 -0
  29. package/dist/graphml-loader.js +13 -0
  30. package/dist/graphml-loader.js.map +1 -0
  31. package/dist/index.cjs +1022 -0
  32. package/dist/index.cjs.map +7 -0
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +6 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/lib/parse-dot.d.ts +4 -0
  38. package/dist/lib/parse-dot.d.ts.map +1 -0
  39. package/dist/lib/parse-dot.js +724 -0
  40. package/dist/lib/parse-dot.js.map +1 -0
  41. package/dist/lib/parse-graphml.d.ts +6 -0
  42. package/dist/lib/parse-graphml.d.ts.map +1 -0
  43. package/dist/lib/parse-graphml.js +296 -0
  44. package/dist/lib/parse-graphml.js.map +1 -0
  45. package/package.json +58 -0
  46. package/src/dot-loader-types.ts +24 -0
  47. package/src/dot-loader.ts +14 -0
  48. package/src/dot-types.ts +38 -0
  49. package/src/graph-types.ts +33 -0
  50. package/src/graphml-loader-types.ts +24 -0
  51. package/src/graphml-loader.ts +14 -0
  52. package/src/index.ts +8 -0
  53. package/src/lib/parse-dot.ts +924 -0
  54. package/src/lib/parse-graphml.ts +414 -0
@@ -0,0 +1,924 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import type {DOTGraphData} from '../dot-types';
6
+
7
+ /** Internal DOT parsing representation. */
8
+ type DOTAttributeMap = Record<string, unknown>;
9
+
10
+ /** Internal DOT parsing representation. */
11
+ type ParsedNode = {
12
+ /** Id in the DOT parse state. */
13
+ id: string;
14
+ /** Attributes in the DOT parse state. */
15
+ attributes: DOTAttributeMap;
16
+ /** Subgraphs in the DOT parse state. */
17
+ subgraphs: Set<string>;
18
+ };
19
+
20
+ /** Internal DOT parsing representation. */
21
+ type ParsedEdge = {
22
+ /** Id in the DOT parse state. */
23
+ id: string;
24
+ /** SourceId in the DOT parse state. */
25
+ sourceId: string;
26
+ /** TargetId in the DOT parse state. */
27
+ targetId: string;
28
+ /** Directed in the DOT parse state. */
29
+ directed: boolean;
30
+ /** Attributes in the DOT parse state. */
31
+ attributes: DOTAttributeMap;
32
+ /** Subgraphs in the DOT parse state. */
33
+ subgraphs: string[];
34
+ };
35
+
36
+ /** Internal DOT parsing representation. */
37
+ type ParsedSubgraph = {
38
+ /** Id in the DOT parse state. */
39
+ id: string;
40
+ /** Attributes in the DOT parse state. */
41
+ attributes: DOTAttributeMap;
42
+ /** ParentId in the DOT parse state. */
43
+ parentId?: string | null;
44
+ };
45
+
46
+ /** Internal DOT parsing representation. */
47
+ type DOTParseResult = {
48
+ /** Id in the DOT parse state. */
49
+ id?: string;
50
+ /** Directed in the DOT parse state. */
51
+ directed: boolean;
52
+ /** Strict in the DOT parse state. */
53
+ strict: boolean;
54
+ /** GraphAttributes in the DOT parse state. */
55
+ graphAttributes: DOTAttributeMap;
56
+ /** Nodes in the DOT parse state. */
57
+ nodes: Map<string, ParsedNode>;
58
+ /** Edges in the DOT parse state. */
59
+ edges: ParsedEdge[];
60
+ /** Subgraphs in the DOT parse state. */
61
+ subgraphs: Map<string, ParsedSubgraph>;
62
+ };
63
+
64
+ /** Parses DOT source into plain graph records without a visualization dependency. */
65
+ export function parseDOT(input: string): DOTGraphData {
66
+ const parsed = parseDOTDocument(input);
67
+ const subgraphs = Array.from(parsed.subgraphs.values(), (subgraph) => ({
68
+ ...subgraph,
69
+ attributes: {...subgraph.attributes}
70
+ }));
71
+ return {
72
+ shape: 'plain-graph-data',
73
+ nodes: Array.from(parsed.nodes.values(), (node) => ({
74
+ id: node.id,
75
+ ...(typeof node.attributes.label === 'string' ? {label: node.attributes.label} : {}),
76
+ attributes: {
77
+ ...node.attributes,
78
+ ...(node.subgraphs.size
79
+ ? {subgraphs: Array.from(node.subgraphs, (id) => describeSubgraph(id, parsed.subgraphs))}
80
+ : {})
81
+ }
82
+ })),
83
+ edges: parsed.edges.map((edge) => ({
84
+ id: edge.id,
85
+ sourceId: edge.sourceId,
86
+ targetId: edge.targetId,
87
+ directed: edge.directed,
88
+ ...(typeof edge.attributes.label === 'string' ? {label: edge.attributes.label} : {}),
89
+ attributes: {
90
+ ...edge.attributes,
91
+ ...(edge.subgraphs.length
92
+ ? {subgraphs: edge.subgraphs.map((id) => describeSubgraph(id, parsed.subgraphs))}
93
+ : {})
94
+ }
95
+ })),
96
+ metadata: {
97
+ id: parsed.id,
98
+ directed: parsed.directed,
99
+ strict: parsed.strict,
100
+ attributes: {...parsed.graphAttributes},
101
+ subgraphs
102
+ }
103
+ };
104
+ }
105
+
106
+ /** Copies a subgraph descriptor into node or edge attributes. */
107
+ function describeSubgraph(
108
+ id: string,
109
+ descriptors: Map<string, ParsedSubgraph>
110
+ ): {id: string; attributes: DOTAttributeMap; parentId?: string | null} {
111
+ const subgraph = descriptors.get(id)!;
112
+ return {
113
+ id,
114
+ attributes: {...subgraph.attributes},
115
+ parentId: subgraph.parentId
116
+ };
117
+ }
118
+
119
+ /** Internal DOT parsing representation. */
120
+ type TokenType =
121
+ | 'identifier'
122
+ | 'string'
123
+ | 'html'
124
+ | 'arrow'
125
+ | 'lbrace'
126
+ | 'rbrace'
127
+ | 'lbrack'
128
+ | 'rbrack'
129
+ | 'equals'
130
+ | 'comma'
131
+ | 'semicolon';
132
+
133
+ /** Internal DOT parsing representation. */
134
+ type Token = {
135
+ /** Type in the DOT parse state. */
136
+ type: TokenType;
137
+ /** Value in the DOT parse state. */
138
+ value: string;
139
+ };
140
+
141
+ /** Internal DOT parsing representation. */
142
+ type ScopeContext = {
143
+ /** Id in the DOT parse state. */
144
+ id?: string;
145
+ /** NodeDefaults in the DOT parse state. */
146
+ nodeDefaults: DOTAttributeMap;
147
+ /** EdgeDefaults in the DOT parse state. */
148
+ edgeDefaults: DOTAttributeMap;
149
+ /** GraphAttributes in the DOT parse state. */
150
+ graphAttributes: DOTAttributeMap;
151
+ };
152
+
153
+ /** Recursive parser for the supported DOT statement subset. */
154
+ class DOTParser {
155
+ /** Tokens for DOT parsing. */
156
+ private readonly tokens: Token[];
157
+ /** Position for DOT parsing. */
158
+ private position = 0;
159
+ /** Accumulated graph records and attributes. */
160
+ private readonly result: DOTParseResult = {
161
+ directed: false,
162
+ strict: false,
163
+ graphAttributes: Object.create(null),
164
+ nodes: new Map(),
165
+ edges: [],
166
+ subgraphs: new Map()
167
+ };
168
+
169
+ /** Scopes for DOT parsing. */
170
+ private readonly scopes: ScopeContext[] = [
171
+ {
172
+ nodeDefaults: Object.create(null),
173
+ edgeDefaults: Object.create(null),
174
+ graphAttributes: Object.create(null)
175
+ }
176
+ ];
177
+
178
+ /** Subgraph counter for DOT parsing. */
179
+ private subgraphCounter = 0;
180
+ /** Edge counter for DOT parsing. */
181
+ private edgeCounter = 0;
182
+
183
+ /** Edge lookup used to coalesce parallel edges in strict graphs. */
184
+ private readonly strictEdges = new Map<string, ParsedEdge>();
185
+
186
+ /** Persistent attributes and defaults for reopened named subgraphs. */
187
+ private readonly subgraphScopes = new Map<string, ScopeContext>();
188
+
189
+ /** Explicit names reserved so anonymous subgraph IDs cannot collide with them. */
190
+ private readonly namedSubgraphIds = new Set<string>();
191
+
192
+ /** Initializes the token stream. */
193
+ constructor(tokens: Token[]) {
194
+ this.tokens = tokens;
195
+ for (let index = 0; index < tokens.length - 1; index++) {
196
+ if (isKeyword(tokens[index], 'subgraph') && isIdentifierLike(tokens[index + 1])) {
197
+ this.namedSubgraphIds.add(tokens[index + 1].value);
198
+ }
199
+ }
200
+ }
201
+
202
+ /** Parses one complete DOT document. */
203
+ parse(): DOTParseResult {
204
+ this.parseGraph();
205
+ this.consumeOptionalSemicolon();
206
+ if (this.peek()) {
207
+ throw new Error('Unexpected content after DOT graph.');
208
+ }
209
+ return this.result;
210
+ }
211
+
212
+ /** Reads graph identity, declaration, and statements. */
213
+ private parseGraph(): void {
214
+ const strictToken = this.peek();
215
+ if (strictToken && isKeyword(strictToken, 'strict')) {
216
+ this.consume();
217
+ this.result.strict = true;
218
+ }
219
+
220
+ const typeToken = this.peek();
221
+ if (!typeToken || !isGraphType(typeToken)) {
222
+ throw new Error('DOT graph must start with graph or digraph keyword.');
223
+ }
224
+ this.consume();
225
+ this.result.directed = isKeyword(typeToken, 'digraph');
226
+
227
+ const idToken = this.peek();
228
+ if (idToken && isIdentifierLike(idToken)) {
229
+ this.result.id = parseIdentifierValue(this.consume());
230
+ }
231
+
232
+ this.expect('lbrace');
233
+ while (!this.match('rbrace')) {
234
+ if (!this.parseStatement()) {
235
+ throw new Error('Expected closing brace in DOT graph.');
236
+ }
237
+ }
238
+ this.result.graphAttributes = {...this.scopes[0].graphAttributes};
239
+ }
240
+
241
+ /** Dispatches a graph statement. */
242
+ private parseStatement(): boolean {
243
+ if (this.consumeSemicolonIfPresent()) {
244
+ return true;
245
+ }
246
+
247
+ const token = this.peek();
248
+ if (!token) {
249
+ return false;
250
+ }
251
+
252
+ if (this.tryParseSubgraphStatement(token)) {
253
+ return true;
254
+ }
255
+
256
+ if (this.tryParseKeywordStatement(token)) {
257
+ return true;
258
+ }
259
+
260
+ if (this.tryParseAssignmentStatement(token)) {
261
+ return true;
262
+ }
263
+
264
+ if (isIdentifierLike(token)) {
265
+ this.parseNodeOrEdgeStatement();
266
+ this.consumeOptionalSemicolon();
267
+ return true;
268
+ }
269
+
270
+ throw new Error(`Unexpected token: ${token.value}`);
271
+ }
272
+
273
+ /** Consumes an empty statement. */
274
+ private consumeSemicolonIfPresent(): boolean {
275
+ const next = this.peek();
276
+ if (next?.type === 'semicolon') {
277
+ this.consume();
278
+ return true;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ /** Reads a standalone subgraph statement. */
284
+ private tryParseSubgraphStatement(token: Token): boolean {
285
+ if (token.type !== 'lbrace' && !isKeyword(token, 'subgraph')) {
286
+ return false;
287
+ }
288
+ this.parseSubgraph();
289
+ return true;
290
+ }
291
+
292
+ /** Applies graph, node, or edge defaults. */
293
+ private tryParseKeywordStatement(token: Token): boolean {
294
+ if (!(isKeyword(token, 'graph') || isKeyword(token, 'node') || isKeyword(token, 'edge'))) {
295
+ return false;
296
+ }
297
+
298
+ this.consume();
299
+ const explicitAttributes = this.parseAttributeList();
300
+
301
+ if (isKeyword(token, 'graph')) {
302
+ Object.assign(this.currentScope().graphAttributes, explicitAttributes);
303
+ } else if (isKeyword(token, 'node')) {
304
+ Object.assign(this.currentScope().nodeDefaults, explicitAttributes);
305
+ } else {
306
+ Object.assign(this.currentScope().edgeDefaults, explicitAttributes);
307
+ }
308
+
309
+ this.consumeOptionalSemicolon();
310
+ return true;
311
+ }
312
+
313
+ /** Reads a graph attribute assignment. */
314
+ private tryParseAssignmentStatement(token: Token): boolean {
315
+ if (!isIdentifierLike(token)) {
316
+ return false;
317
+ }
318
+ const next = this.peek(1);
319
+ if (next?.type !== 'equals') {
320
+ return false;
321
+ }
322
+
323
+ const key = parseIdentifierValue(this.consume());
324
+ this.consume();
325
+ const valueToken = this.consume();
326
+ const value = parseAttributeValue(valueToken);
327
+ this.currentScope().graphAttributes[key] = value;
328
+ this.consumeOptionalSemicolon();
329
+ return true;
330
+ }
331
+
332
+ /** Reads a node statement or chained edges. */
333
+ private parseNodeOrEdgeStatement(): void {
334
+ const first = parseIdentifierValue(this.consume());
335
+ const references: string[] = [first];
336
+ const operators: string[] = [];
337
+
338
+ let operatorToken = this.match('arrow');
339
+ while (operatorToken) {
340
+ operators.push(operatorToken.value);
341
+ const referenceToken = this.consume();
342
+ if (!isIdentifierLike(referenceToken)) {
343
+ throw new Error('Expected node identifier in edge statement.');
344
+ }
345
+ references.push(parseIdentifierValue(referenceToken));
346
+ operatorToken = this.match('arrow');
347
+ }
348
+
349
+ if (operators.length === 0) {
350
+ const explicitAttributes = this.parseAttributeList();
351
+ this.addNode(first, explicitAttributes);
352
+ return;
353
+ }
354
+
355
+ const explicitAttributes = this.parseAttributeList();
356
+ this.addEdgeChain(references, operators, explicitAttributes);
357
+ }
358
+
359
+ /** Merges explicit attributes into a node. */
360
+ private addNode(id: string, explicitAttributes: DOTAttributeMap): void {
361
+ const membership = this.getCurrentSubgraphChain();
362
+ const node = this.ensureNode(id, membership);
363
+ node.attributes = {...node.attributes, ...explicitAttributes};
364
+ }
365
+
366
+ /** Expands an edge chain into node and edge records. */
367
+ private addEdgeChain(
368
+ nodes: string[],
369
+ operators: string[],
370
+ explicitAttributes: DOTAttributeMap
371
+ ): void {
372
+ const membership = this.getCurrentSubgraphChain();
373
+ const defaults = this.currentScope().edgeDefaults;
374
+ const attributes = {...defaults, ...explicitAttributes};
375
+
376
+ for (let index = 0; index < nodes.length - 1; index++) {
377
+ const sourceId = nodes[index];
378
+ const targetId = nodes[index + 1];
379
+ const operator = operators[index];
380
+ const directed = operator === '->';
381
+ if (directed !== this.result.directed) {
382
+ throw new Error('DOT edge operator must match graph or digraph declaration.');
383
+ }
384
+
385
+ const edgeId = deriveEdgeId(attributes, sourceId, targetId, ++this.edgeCounter);
386
+ const edgeAttributes = {...attributes};
387
+ this.ensureNode(sourceId, membership);
388
+ this.ensureNode(targetId, membership);
389
+
390
+ const strictKey = JSON.stringify(
391
+ directed ? [sourceId, targetId] : [sourceId, targetId].sort()
392
+ );
393
+ const existingEdge = this.result.strict ? this.strictEdges.get(strictKey) : undefined;
394
+ if (existingEdge) {
395
+ existingEdge.attributes = {...existingEdge.attributes, ...explicitAttributes};
396
+ existingEdge.directed = deriveDirectedFlag(existingEdge.attributes, directed);
397
+ existingEdge.subgraphs = Array.from(new Set([...existingEdge.subgraphs, ...membership]));
398
+ continue;
399
+ }
400
+ const directedOverride = deriveDirectedFlag(edgeAttributes, directed);
401
+ const edge: ParsedEdge = {
402
+ id: edgeId,
403
+ sourceId,
404
+ targetId,
405
+ directed: directedOverride,
406
+ attributes: edgeAttributes,
407
+ subgraphs: membership
408
+ };
409
+ this.result.edges.push(edge);
410
+ if (this.result.strict) {
411
+ this.strictEdges.set(strictKey, edge);
412
+ }
413
+ }
414
+ }
415
+
416
+ /** Reads a nested scope with inherited defaults. */
417
+ private parseSubgraph(): void {
418
+ let idToken = this.peek();
419
+ let subgraphId: string;
420
+
421
+ if (idToken && isKeyword(idToken, 'subgraph')) {
422
+ this.consume();
423
+ idToken = this.peek();
424
+ }
425
+
426
+ if (idToken && isIdentifierLike(idToken)) {
427
+ subgraphId = parseIdentifierValue(this.consume());
428
+ } else {
429
+ do {
430
+ subgraphId = `subgraph_${++this.subgraphCounter}`;
431
+ } while (this.namedSubgraphIds.has(subgraphId));
432
+ }
433
+
434
+ this.expect('lbrace');
435
+ const parentId = this.findCurrentSubgraphId();
436
+ let context = this.subgraphScopes.get(subgraphId);
437
+ if (!context) {
438
+ context = {
439
+ id: subgraphId,
440
+ nodeDefaults: Object.assign(Object.create(null), this.currentScope().nodeDefaults),
441
+ edgeDefaults: Object.assign(Object.create(null), this.currentScope().edgeDefaults),
442
+ graphAttributes: Object.create(null)
443
+ };
444
+ this.subgraphScopes.set(subgraphId, context);
445
+ this.result.subgraphs.set(subgraphId, {
446
+ id: subgraphId,
447
+ attributes: context.graphAttributes,
448
+ parentId
449
+ });
450
+ }
451
+ this.scopes.push(context);
452
+
453
+ let shouldContinue = true;
454
+ while (shouldContinue) {
455
+ if (this.match('rbrace')) {
456
+ break;
457
+ }
458
+ shouldContinue = this.parseStatement();
459
+ if (!shouldContinue) {
460
+ throw new Error('Expected closing brace in DOT subgraph.');
461
+ }
462
+ }
463
+
464
+ this.scopes.pop();
465
+ }
466
+
467
+ /** Reads repeated attribute lists. */
468
+ private parseAttributeList(): DOTAttributeMap {
469
+ const attributes: DOTAttributeMap = Object.create(null);
470
+ while (this.match('lbrack')) {
471
+ while (!this.match('rbrack')) {
472
+ const keyToken = this.consume();
473
+ if (!isIdentifierLike(keyToken)) {
474
+ throw new Error('Expected attribute name.');
475
+ }
476
+ const key = parseIdentifierValue(keyToken);
477
+ let value: unknown = true;
478
+ if (this.match('equals')) {
479
+ const valueToken = this.consume();
480
+ value = parseAttributeValue(valueToken);
481
+ }
482
+ attributes[key] = value;
483
+ if (this.peek()?.type === 'comma' || this.peek()?.type === 'semicolon') {
484
+ this.consume();
485
+ }
486
+ }
487
+ }
488
+ return attributes;
489
+ }
490
+
491
+ /** Creates an implicit node and records its subgraph memberships. */
492
+ private ensureNode(id: string, membership: string[]): ParsedNode {
493
+ let node = this.result.nodes.get(id);
494
+ if (!node) {
495
+ const defaults = this.currentScope().nodeDefaults;
496
+ node = {id, attributes: {...defaults}, subgraphs: new Set()};
497
+ this.result.nodes.set(id, node);
498
+ }
499
+ membership.forEach((subgraphId) => node.subgraphs.add(subgraphId));
500
+ return node;
501
+ }
502
+
503
+ /** Lists enclosing subgraphs. */
504
+ private getCurrentSubgraphChain(): string[] {
505
+ const chain: string[] = [];
506
+ for (const scope of this.scopes) {
507
+ if (scope.id) {
508
+ chain.push(scope.id);
509
+ }
510
+ }
511
+ return chain;
512
+ }
513
+
514
+ /** Finds the nearest enclosing named or generated subgraph. */
515
+ private findCurrentSubgraphId(): string | undefined {
516
+ for (let index = this.scopes.length - 1; index >= 0; index--) {
517
+ const scope = this.scopes[index];
518
+ if (scope.id) {
519
+ return scope.id;
520
+ }
521
+ }
522
+ return undefined;
523
+ }
524
+
525
+ /** Returns the active attribute scope. */
526
+ private currentScope(): ScopeContext {
527
+ return this.scopes[this.scopes.length - 1];
528
+ }
529
+
530
+ /** Consumes an optional statement terminator. */
531
+ private consumeOptionalSemicolon(): void {
532
+ const next = this.peek();
533
+ if (next?.type === 'semicolon') {
534
+ this.consume();
535
+ }
536
+ }
537
+
538
+ /** Requires the next token to have the requested type. */
539
+ private expect(type: TokenType): Token {
540
+ const token = this.consume();
541
+ if (!token || token.type !== type) {
542
+ throw new Error(`Expected token ${type}.`);
543
+ }
544
+ return token;
545
+ }
546
+
547
+ /** Consumes the next token or reports truncated input. */
548
+ private consume(): Token {
549
+ const token = this.tokens[this.position];
550
+ if (!token) {
551
+ throw new Error('Unexpected end of DOT input.');
552
+ }
553
+ this.position++;
554
+ return token;
555
+ }
556
+
557
+ /** Consumes a token when its type matches. */
558
+ private match(type: TokenType): Token | null {
559
+ const token = this.peek();
560
+ if (token && token.type === type) {
561
+ this.position++;
562
+ return token;
563
+ }
564
+ return null;
565
+ }
566
+
567
+ /** Inspects a token without consuming it. */
568
+ private peek(offset = 0): Token | null {
569
+ return this.tokens[this.position + offset] ?? null;
570
+ }
571
+ }
572
+
573
+ /** Tokenizes and parses a DOT document. */
574
+ function parseDOTDocument(input: string): DOTParseResult {
575
+ const parser = new DOTParser(tokenize(input));
576
+ return parser.parse();
577
+ }
578
+
579
+ const IDENTIFIER_TERMINATORS = new Set(['{', '}', '[', ']', '=', ';', ',', '"', '<', '#']);
580
+
581
+ /** Builds a token stream from DOT text. */
582
+ function tokenize(input: string): Token[] {
583
+ const tokens: Token[] = [];
584
+ let index = 0;
585
+
586
+ while (index < input.length) {
587
+ const {token, nextIndex} = readNextToken(input, index);
588
+ if (nextIndex <= index) {
589
+ throw new Error(`Tokenizer did not advance at position ${index}.`);
590
+ }
591
+ if (token) {
592
+ tokens.push(token);
593
+ }
594
+ index = nextIndex;
595
+ }
596
+
597
+ return tokens;
598
+ }
599
+
600
+ /** Reads a token or skips whitespace and comments. */
601
+ function readNextToken(input: string, index: number): {token: Token | null; nextIndex: number} {
602
+ if (index >= input.length) {
603
+ return {token: null, nextIndex: input.length};
604
+ }
605
+
606
+ const character = input[index];
607
+
608
+ if (isWhitespace(character)) {
609
+ return {token: null, nextIndex: index + 1};
610
+ }
611
+
612
+ const commentEnd = skipComment(input, index);
613
+ if (commentEnd !== null) {
614
+ return {token: null, nextIndex: commentEnd};
615
+ }
616
+
617
+ const arrowToken = readArrowToken(input, index);
618
+ if (arrowToken) {
619
+ return arrowToken;
620
+ }
621
+
622
+ const punctuation = readPunctuationToken(character);
623
+ if (punctuation) {
624
+ return {token: punctuation, nextIndex: index + 1};
625
+ }
626
+
627
+ if (character === '"') {
628
+ const {value, nextIndex} = readQuotedString(input, index + 1);
629
+ return {token: {type: 'string', value}, nextIndex};
630
+ }
631
+
632
+ if (character === '<') {
633
+ const {value, nextIndex} = readHtmlString(input, index + 1);
634
+ return {token: {type: 'html', value}, nextIndex};
635
+ }
636
+
637
+ const identifier = readIdentifier(input, index);
638
+ if (identifier.value) {
639
+ return {
640
+ token: {type: 'identifier', value: identifier.value},
641
+ nextIndex: identifier.nextIndex
642
+ };
643
+ }
644
+
645
+ throw new Error(`Unexpected token at position ${index}.`);
646
+ }
647
+
648
+ /** Skips line, block, or hash comments. */
649
+ function skipComment(input: string, index: number): number | null {
650
+ const character = input[index];
651
+ if (character === '/') {
652
+ const next = input[index + 1];
653
+ if (next === '/') {
654
+ return skipLineComment(input, index + 2);
655
+ }
656
+ if (next === '*') {
657
+ return skipBlockComment(input, index + 2);
658
+ }
659
+ return null;
660
+ }
661
+
662
+ if (character === '#') {
663
+ return skipLineComment(input, index + 1);
664
+ }
665
+
666
+ return null;
667
+ }
668
+
669
+ /** Scans to the next line break. */
670
+ function skipLineComment(input: string, startIndex: number): number {
671
+ let cursor = startIndex;
672
+ while (cursor < input.length && !isLineBreak(input[cursor])) {
673
+ cursor++;
674
+ }
675
+ return cursor;
676
+ }
677
+
678
+ /** Scans to a block comment terminator. */
679
+ function skipBlockComment(input: string, startIndex: number): number {
680
+ let cursor = startIndex;
681
+ while (cursor < input.length) {
682
+ if (input[cursor] === '*' && input[cursor + 1] === '/') {
683
+ return cursor + 2;
684
+ }
685
+ cursor++;
686
+ }
687
+ throw new Error('Unterminated block comment in DOT source.');
688
+ }
689
+
690
+ /** Recognizes directed and undirected edge operators. */
691
+ function readArrowToken(input: string, index: number): {token: Token; nextIndex: number} | null {
692
+ const next = input[index + 1];
693
+ if (input[index] === '-' && next === '-') {
694
+ return {token: {type: 'arrow', value: '--'}, nextIndex: index + 2};
695
+ }
696
+ if (input[index] === '-' && next === '>') {
697
+ return {token: {type: 'arrow', value: '->'}, nextIndex: index + 2};
698
+ }
699
+ return null;
700
+ }
701
+
702
+ /** Recognizes DOT structural punctuation. */
703
+ function readPunctuationToken(character: string): Token | null {
704
+ switch (character) {
705
+ case '{':
706
+ return {type: 'lbrace', value: character};
707
+ case '}':
708
+ return {type: 'rbrace', value: character};
709
+ case '[':
710
+ return {type: 'lbrack', value: character};
711
+ case ']':
712
+ return {type: 'rbrack', value: character};
713
+ case '=':
714
+ return {type: 'equals', value: character};
715
+ case ',':
716
+ return {type: 'comma', value: character};
717
+ case ';':
718
+ return {type: 'semicolon', value: character};
719
+ default:
720
+ return null;
721
+ }
722
+ }
723
+
724
+ /** Reads a quoted string and decodes its escapes. */
725
+ function readQuotedString(input: string, startIndex: number): {value: string; nextIndex: number} {
726
+ let value = '';
727
+ let index = startIndex;
728
+ while (index < input.length) {
729
+ const character = input[index];
730
+ if (character === '"') {
731
+ return {value, nextIndex: index + 1};
732
+ }
733
+ if (character === '\\') {
734
+ const escape = readEscapedCharacter(input, index + 1);
735
+ value += escape.value;
736
+ index = escape.nextIndex;
737
+ } else {
738
+ value += character;
739
+ index++;
740
+ }
741
+ }
742
+ throw new Error('Unterminated string literal in DOT source.');
743
+ }
744
+
745
+ /** Decodes one quoted-string escape. */
746
+ function readEscapedCharacter(
747
+ input: string,
748
+ startIndex: number
749
+ ): {value: string; nextIndex: number} {
750
+ const next = input[startIndex];
751
+ switch (next) {
752
+ case 'n':
753
+ case 'l':
754
+ case 'L':
755
+ return {value: '\n', nextIndex: startIndex + 1};
756
+ case 't':
757
+ return {value: '\t', nextIndex: startIndex + 1};
758
+ case 'r':
759
+ return {value: '\r', nextIndex: startIndex + 1};
760
+ case '"':
761
+ return {value: '"', nextIndex: startIndex + 1};
762
+ case '\\':
763
+ return {value: '\\', nextIndex: startIndex + 1};
764
+ default: {
765
+ if (typeof next === 'undefined') {
766
+ throw new Error('Unterminated escape sequence in DOT source.');
767
+ }
768
+ return {value: next, nextIndex: startIndex + 1};
769
+ }
770
+ }
771
+ }
772
+
773
+ /** Reads a balanced HTML-like label. */
774
+ function readHtmlString(input: string, startIndex: number): {value: string; nextIndex: number} {
775
+ let value = '<';
776
+ let depth = 1;
777
+ let index = startIndex;
778
+ while (index < input.length) {
779
+ const character = input[index];
780
+ value += character;
781
+ if (character === '<') {
782
+ depth++;
783
+ } else if (character === '>') {
784
+ depth--;
785
+ if (depth === 0) {
786
+ return {value, nextIndex: index + 1};
787
+ }
788
+ }
789
+ index++;
790
+ }
791
+ throw new Error('Unterminated HTML-like string literal in DOT source.');
792
+ }
793
+
794
+ /** Reads an unquoted identifier. */
795
+ function readIdentifier(input: string, startIndex: number): {value: string; nextIndex: number} {
796
+ let index = startIndex;
797
+ let value = '';
798
+ while (index < input.length && !isIdentifierTerminator(input, index)) {
799
+ value += input[index];
800
+ index++;
801
+ }
802
+ if (value.includes(':')) {
803
+ throw new Error('DOT node ports are not supported; quote identifiers containing colons.');
804
+ }
805
+ /** Return {value, nextIndex in the DOT parse state. */
806
+ return {value, nextIndex: index};
807
+ }
808
+
809
+ /** Tests whether a character terminates an identifier. */
810
+ function isIdentifierTerminator(input: string, index: number): boolean {
811
+ const character = input[index];
812
+ if (isWhitespace(character) || IDENTIFIER_TERMINATORS.has(character)) {
813
+ return true;
814
+ }
815
+ if (isArrowOperatorStart(input, index)) {
816
+ return true;
817
+ }
818
+ if (isCommentStart(input, index)) {
819
+ return true;
820
+ }
821
+ return false;
822
+ }
823
+
824
+ /** Tests whether an edge operator starts at this position. */
825
+ function isArrowOperatorStart(input: string, index: number): boolean {
826
+ if (input[index] !== '-') {
827
+ return false;
828
+ }
829
+ const next = input[index + 1];
830
+ return next === '-' || next === '>';
831
+ }
832
+
833
+ /** Tests whether a slash comment starts at this position. */
834
+ function isCommentStart(input: string, index: number): boolean {
835
+ if (input[index] !== '/') {
836
+ return false;
837
+ }
838
+ const next = input[index + 1];
839
+ return next === '/' || next === '*';
840
+ }
841
+
842
+ /** Recognizes supported whitespace characters. */
843
+ function isWhitespace(character: string): boolean {
844
+ return (
845
+ character === ' ' ||
846
+ character === '\n' ||
847
+ character === '\r' ||
848
+ character === '\t' ||
849
+ character === '\f'
850
+ );
851
+ }
852
+
853
+ /** Recognizes a line break. */
854
+ function isLineBreak(character: string): boolean {
855
+ return character === '\n' || character === '\r';
856
+ }
857
+
858
+ /** Matches an unquoted keyword case-insensitively. */
859
+ function isKeyword(token: Token, keyword: string): boolean {
860
+ return token.type === 'identifier' && token.value.toLowerCase() === keyword.toLowerCase();
861
+ }
862
+
863
+ /** Recognizes graph and digraph declarations. */
864
+ function isGraphType(token: Token): boolean {
865
+ return isKeyword(token, 'graph') || isKeyword(token, 'digraph');
866
+ }
867
+
868
+ /** Tests whether a token can name a node or attribute. */
869
+ function isIdentifierLike(token: Token): boolean {
870
+ return token.type === 'identifier' || token.type === 'string' || token.type === 'html';
871
+ }
872
+
873
+ /** Preserves the text of a DOT identifier. */
874
+ function parseIdentifierValue(token: Token): string {
875
+ return token.value;
876
+ }
877
+
878
+ /** Preserves quoted text and converts unquoted numeric attributes. */
879
+ function parseAttributeValue(token: Token): unknown {
880
+ if (token.type === 'string' || token.type === 'html') {
881
+ return token.value;
882
+ }
883
+ if (token.type === 'identifier') {
884
+ const numeric = Number(token.value);
885
+ if (!Number.isNaN(numeric)) {
886
+ return numeric;
887
+ }
888
+ return token.value;
889
+ }
890
+ throw new Error('Invalid attribute value in DOT input.');
891
+ }
892
+
893
+ /** Uses an explicit ID or generates a stable edge identifier. */
894
+ function deriveEdgeId(
895
+ attributes: DOTAttributeMap,
896
+ sourceId: string,
897
+ targetId: string,
898
+ counter: number
899
+ ): string {
900
+ const candidate = attributes.id ?? attributes.Id ?? attributes.ID;
901
+ if (typeof candidate === 'string' || typeof candidate === 'number') {
902
+ return String(candidate);
903
+ }
904
+ return `${String(sourceId)}-${String(targetId)}-${counter}`;
905
+ }
906
+
907
+ /** Applies direction flags over the edge operator default. */
908
+ function deriveDirectedFlag(attributes: DOTAttributeMap, defaultDirected: boolean): boolean {
909
+ const candidate = attributes.directed;
910
+ if (typeof candidate === 'boolean') {
911
+ return candidate;
912
+ }
913
+
914
+ const directionAttribute = attributes.dir;
915
+ if (typeof directionAttribute === 'string') {
916
+ const normalized = directionAttribute.toLowerCase();
917
+ if (normalized === 'none') {
918
+ return false;
919
+ }
920
+ return true;
921
+ }
922
+
923
+ return defaultDirected;
924
+ }