• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 20661584252

02 Jan 2026 03:50PM UTC coverage: 77.924% (+7.3%) from 70.62%
20661584252

push

github

apowers313
ci: fix flakey performance test

13438 of 17822 branches covered (75.4%)

Branch coverage included in aggregate %.

41247 of 52355 relevant lines covered (78.78%)

145534.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.08
/graphty-element/src/data/DOTDataSource.ts
1
import type { AdHocData } from "../config/common.js";
2
import { BaseDataSourceConfig, DataSource, DataSourceChunk } from "./DataSource.js";
15✔
3

4
// DOT has no additional config currently, so just use the base config
5
export type DOTDataSourceConfig = BaseDataSourceConfig;
6

7
interface DOTNode {
8
    id: string;
9
    attributes: Record<string, string | number>;
10
}
11

12
interface DOTEdge {
13
    src: string;
14
    dst: string;
15
    attributes: Record<string, string | number>;
16
}
17

18
/**
19
 * Data source for loading graph data from DOT (Graphviz) format files.
20
 * Supports directed and undirected graphs with node and edge attributes.
21
 */
22
export class DOTDataSource extends DataSource {
15✔
23
    static readonly type = "dot";
18✔
24

25
    private config: DOTDataSourceConfig;
26

27
    /**
28
     * Creates a new DOTDataSource instance.
29
     * @param config - Configuration options for DOT parsing and data loading
30
     */
31
    constructor(config: DOTDataSourceConfig) {
18✔
32
        super(config.errorLimit ?? 100, config.chunkSize);
30✔
33
        this.config = config;
30✔
34
    }
30✔
35

36
    protected getConfig(): BaseDataSourceConfig {
18✔
37
        return this.config;
30✔
38
    }
30✔
39

40
    /**
41
     * Fetches and parses DOT format data into graph chunks.
42
     * @yields DataSourceChunk objects containing parsed nodes and edges
43
     */
44
    async *sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown> {
18✔
45
        // Get DOT content
46
        const dotContent = await this.getContent();
30✔
47

48
        // Parse DOT
49
        const { nodes, edges } = this.parseDOT(dotContent);
29✔
50

51
        // Use shared chunking helper
52
        yield* this.chunkData(nodes, edges);
29✔
53
    }
30✔
54

55
    private parseDOT(content: string): { nodes: AdHocData[]; edges: AdHocData[] } {
18✔
56
        // Handle empty content gracefully
57
        if (content.trim() === "") {
29!
58
            return { nodes: [], edges: [] };
1✔
59
        }
1✔
60

61
        // Remove comments
62
        let cleaned = content.replace(/\/\/.*$/gm, ""); // Single-line comments
28✔
63
        cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, ""); // Multi-line comments
28✔
64

65
        // Tokenize with error handling
66
        let tokens: string[];
28✔
67
        try {
28✔
68
            tokens = this.tokenize(cleaned);
28✔
69
        } catch (error) {
29!
70
            const canContinue = this.errorAggregator.addError({
×
71
                message: `Failed to tokenize DOT content: ${error instanceof Error ? error.message : String(error)}`,
×
72
                category: "parse-error",
×
73
            });
×
74

75
            if (!canContinue) {
×
76
                throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
77
            }
×
78

79
            return { nodes: [], edges: [] };
×
80
        }
✔
81

82
        // Handle empty token list
83
        if (tokens.length === 0) {
29!
84
            return { nodes: [], edges: [] };
×
85
        }
✔
86

87
        // Parse structure with error recovery
88
        const { nodes: parsedNodes, edges: parsedEdges } = this.parseTokens(tokens);
28✔
89

90
        // Convert to AdHocData
91
        const nodes = parsedNodes.map((node) => ({
28✔
92
            id: node.id,
6,155✔
93
            ...node.attributes,
6,155✔
94
        })) as unknown as AdHocData[];
28✔
95

96
        const edges = parsedEdges.map((edge) => ({
28✔
97
            src: edge.src,
1,171✔
98
            dst: edge.dst,
1,171✔
99
            ...edge.attributes,
1,171✔
100
        })) as unknown as AdHocData[];
28✔
101

102
        return { nodes, edges };
28✔
103
    }
29✔
104

105
    private tokenize(content: string): string[] {
18✔
106
        const tokens: string[] = [];
28✔
107
        let current = "";
28✔
108
        let inString = false;
28✔
109
        let inHtmlLabel = false;
28✔
110
        let htmlDepth = 0;
28✔
111

112
        for (let i = 0; i < content.length; i++) {
28✔
113
            const char = content[i];
177,891✔
114

115
            // Handle HTML-like labels
116
            if (!inString && char === "<" && content[i + 1] !== "<") {
177,891!
117
                inHtmlLabel = true;
2✔
118
                htmlDepth++;
2✔
119
                current += char;
2✔
120
                continue;
2✔
121
            }
2✔
122

123
            if (inHtmlLabel && char === ">") {
177,891!
124
                current += char;
2✔
125
                htmlDepth--;
2✔
126
                if (htmlDepth === 0) {
2✔
127
                    inHtmlLabel = false;
2✔
128
                }
2✔
129

130
                continue;
2✔
131
            }
2✔
132

133
            if (inHtmlLabel) {
177,891!
134
                current += char;
3✔
135
                if (char === "<") {
3!
136
                    htmlDepth++;
×
137
                }
×
138

139
                continue;
3✔
140
            }
3✔
141

142
            // Handle quoted strings
143
            if (char === '"' && (i === 0 || content[i - 1] !== "\\")) {
177,891✔
144
                if (inString) {
7,227✔
145
                    // End of quoted string - check if followed by port syntax (:port or :port:compass)
146
                    // If so, include the port as part of this token
147
                    if (i + 1 < content.length && content[i + 1] === ":") {
3,613!
148
                        // Continue collecting the port suffix
149
                        let portEnd = i + 2;
34✔
150
                        while (
34✔
151
                            portEnd < content.length &&
34✔
152
                            !/[\s{}[\];,=]/.test(content[portEnd]) &&
102✔
153
                            content[portEnd] !== '"' &&
68✔
154
                            !(
68✔
155
                                content[portEnd] === "-" &&
68!
156
                                portEnd + 1 < content.length &&
×
157
                                (content[portEnd + 1] === ">" || content[portEnd + 1] === "-")
×
158
                            )
159
                        ) {
34✔
160
                            portEnd++;
68✔
161
                        }
68✔
162

163
                        current += `:${content.substring(i + 2, portEnd)}`;
34✔
164
                        i = portEnd - 1; // -1 because loop will increment
34✔
165
                    }
34✔
166

167
                    tokens.push(current);
3,613✔
168
                    current = "";
3,613✔
169
                    inString = false;
3,613✔
170
                } else {
7,189✔
171
                    if (current.trim()) {
3,614!
172
                        tokens.push(current.trim());
×
173
                        current = "";
×
174
                    }
×
175

176
                    inString = true;
3,614✔
177
                }
3,614✔
178

179
                continue;
7,227✔
180
            }
7,227✔
181

182
            if (inString) {
177,891✔
183
                // Handle escape sequences
184
                if (char === "\\" && i + 1 < content.length) {
19,532!
185
                    const next = content[i + 1];
3✔
186
                    if (next === '"' || next === "\\") {
3!
187
                        current += next;
×
188
                        i++;
×
189
                        continue;
×
190
                    }
×
191
                }
3✔
192

193
                current += char;
19,532✔
194
                continue;
19,532✔
195
            }
19,532✔
196

197
            // Handle structural characters
198
            if (["{", "}", "[", "]", ";", ",", "="].includes(char)) {
177,891✔
199
                if (current.trim()) {
23,641✔
200
                    tokens.push(current.trim());
17,644✔
201
                    current = "";
17,644✔
202
                }
17,644✔
203

204
                tokens.push(char);
23,641✔
205
                continue;
23,641✔
206
            }
23,641✔
207

208
            // Handle edge operators
209
            if (char === "-") {
177,891✔
210
                if (i + 1 < content.length) {
1,173✔
211
                    const next = content[i + 1];
1,173✔
212
                    if (next === "-" || next === ">") {
1,173✔
213
                        if (current.trim()) {
1,173!
214
                            tokens.push(current.trim());
1✔
215
                            current = "";
1✔
216
                        }
1✔
217

218
                        tokens.push(char + next);
1,173✔
219
                        i++;
1,173✔
220
                        continue;
1,173✔
221
                    }
1,173✔
222
                }
1,173✔
223
            }
1,173✔
224

225
            // Handle whitespace
226
            if (/\s/.test(char)) {
177,891✔
227
                if (current.trim()) {
29,928✔
228
                    tokens.push(current.trim());
1,377✔
229
                    current = "";
1,377✔
230
                }
1,377✔
231

232
                continue;
29,928✔
233
            }
29,928✔
234

235
            current += char;
96,383✔
236
        }
96,383✔
237

238
        if (current.trim()) {
28!
239
            tokens.push(current.trim());
1✔
240
        }
1✔
241

242
        return tokens;
28✔
243
    }
28✔
244

245
    private parseTokens(tokens: string[]): { nodes: DOTNode[]; edges: DOTEdge[] } {
18✔
246
        const nodes = new Map<string, DOTNode>();
28✔
247
        const edges: DOTEdge[] = [];
28✔
248
        let i = 0;
28✔
249

250
        // Skip graph type and optional name
251
        while (i < tokens.length && !/^(strict|graph|digraph)$/i.exec(tokens[i])) {
28!
252
            i++;
18✔
253
        }
18✔
254

255
        if (/^strict$/i.exec(tokens[i])) {
28!
256
            i++; // Skip 'strict'
1✔
257
        }
1✔
258

259
        if (/^(graph|digraph)$/i.exec(tokens[i])) {
28✔
260
            i++; // Skip 'graph' or 'digraph'
27✔
261
        }
27✔
262

263
        // Skip optional graph name
264
        if (tokens[i] && tokens[i] !== "{") {
28✔
265
            i++; // Skip graph name
14✔
266
        }
14✔
267

268
        // Find opening brace
269
        while (i < tokens.length && tokens[i] !== "{") {
28!
270
            i++;
×
271
        }
×
272
        i++; // Skip '{'
28✔
273

274
        // Parse graph contents (track brace depth for subgraphs)
275
        let braceDepth = 1;
28✔
276
        while (i < tokens.length && braceDepth > 0) {
28✔
277
            const token = tokens[i];
7,371✔
278
            const statementStartIndex = i;
7,371✔
279

280
            try {
7,371✔
281
                // Handle closing brace
282
                if (token === "}") {
7,371✔
283
                    braceDepth--;
33✔
284
                    i++;
33✔
285
                    continue;
33✔
286
                }
33✔
287

288
                // Check if this is an anonymous subgraph starting an edge statement like {A B} -> C
289
                // MUST check this BEFORE the generic brace handling
290
                if (token === "{") {
7,371✔
291
                    // Look ahead to see if there's an edge operator after the closing brace
292
                    const closingIndex = this.findMatchingBrace(tokens, i);
11✔
293
                    if (
11✔
294
                        closingIndex !== -1 &&
11✔
295
                        closingIndex + 1 < tokens.length &&
11✔
296
                        (tokens[closingIndex + 1] === "->" || tokens[closingIndex + 1] === "--")
10✔
297
                    ) {
11!
298
                        // This is an edge statement starting with an anonymous subgraph
299
                        const firstNodes = this.collectSubgraphNodes(tokens, i);
×
300
                        const chainNodes = this.collectEdgeChainNodesFromGroups(
×
301
                            [firstNodes.nodes],
×
302
                            tokens,
×
303
                            firstNodes.endIndex,
×
304
                        );
×
305

306
                        // Create edges between consecutive nodes in the chain
307
                        for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
×
308
                            const srcNodes = chainNodes.nodes[j];
×
309
                            const dstNodes = chainNodes.nodes[j + 1];
×
310

311
                            // Cartesian product: each src connects to each dst
312
                            for (const src of srcNodes) {
×
313
                                for (const dst of dstNodes) {
×
314
                                    // Ensure nodes exist
315
                                    if (!nodes.has(src)) {
×
316
                                        nodes.set(src, { id: src, attributes: {} });
×
317
                                    }
×
318

319
                                    if (!nodes.has(dst)) {
×
320
                                        nodes.set(dst, { id: dst, attributes: {} });
×
321
                                    }
×
322

323
                                    edges.push({ src, dst, attributes: chainNodes.attributes });
×
324
                                }
×
325
                            }
×
326
                        }
×
327

328
                        i = chainNodes.endIndex;
×
329

330
                        // Skip semicolon if present
331
                        if (tokens[i] === ";") {
×
332
                            i++;
×
333
                        }
×
334

335
                        continue;
×
336
                    }
×
337

338
                    // Not an edge statement, just a regular subgraph - continue normal processing
339
                    braceDepth++;
11✔
340
                    i++;
11✔
341
                    continue;
11✔
342
                }
11✔
343

344
                // Skip subgraph keyword (but continue parsing contents normally)
345
                if (/^subgraph$/i.exec(token)) {
7,371✔
346
                    i++;
11✔
347
                    // Skip optional subgraph name
348
                    if (tokens[i] && tokens[i] !== "{" && tokens[i] !== ";") {
11✔
349
                        i++;
11✔
350
                    }
11✔
351

352
                    continue;
11✔
353
                }
11✔
354

355
                // Skip graph/subgraph-level attribute assignments (e.g., label = "value", rankdir = "LR")
356
                if (i + 2 < tokens.length && tokens[i + 1] === "=") {
7,371✔
357
                    i += 3; // Skip: identifier, "=", value
22✔
358

359
                    // Skip optional semicolon
360
                    if (tokens[i] === ";") {
22✔
361
                        i++;
14✔
362
                    }
14✔
363

364
                    continue;
22✔
365
                }
22✔
366

367
                // Handle 'node', 'edge', 'graph' keywords followed by '[' (default attribute statements)
368
                // These are NOT node declarations - they set default attributes for subsequent elements
369
                if (/^(node|edge|graph)$/i.test(token) && tokens[i + 1] === "[") {
7,371!
370
                    i++; // Skip the keyword
18✔
371
                    i++; // Skip '['
18✔
372
                    // Skip the attributes - we don't apply defaults (just structure parsing)
373
                    const attrs = this.parseAttributes(tokens, i);
18✔
374
                    i = attrs.index;
18✔
375

376
                    // Skip optional semicolon
377
                    if (tokens[i] === ";") {
18✔
378
                        i++;
8✔
379
                    }
8✔
380

381
                    continue;
18✔
382
                }
18✔
383

384
                // Check if this is an edge statement (handles chains like a -> b -> c -> d)
385
                if (i + 2 < tokens.length && (tokens[i + 1] === "->" || tokens[i + 1] === "--")) {
7,371✔
386
                    // Collect all nodes in the edge chain, handling anonymous subgraphs
387
                    const chainNodes = this.collectEdgeChainNodes(token, tokens, i + 1);
1,167✔
388

389
                    // Create edges between consecutive nodes in the chain
390
                    for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
1,167✔
391
                        const srcNodes = chainNodes.nodes[j];
1,171✔
392
                        const dstNodes = chainNodes.nodes[j + 1];
1,171✔
393

394
                        // Cartesian product: each src connects to each dst
395
                        for (const src of srcNodes) {
1,171✔
396
                            for (const dst of dstNodes) {
1,171✔
397
                                // Ensure nodes exist
398
                                if (!nodes.has(src)) {
1,171!
399
                                    nodes.set(src, { id: src, attributes: {} });
17✔
400
                                }
17✔
401

402
                                if (!nodes.has(dst)) {
1,171!
403
                                    nodes.set(dst, { id: dst, attributes: {} });
31✔
404
                                }
31✔
405

406
                                edges.push({ src, dst, attributes: chainNodes.attributes });
1,171✔
407
                            }
1,171✔
408
                        }
1,171✔
409
                    }
1,171✔
410

411
                    i = chainNodes.endIndex;
1,167✔
412

413
                    // Skip semicolon if present
414
                    if (tokens[i] === ";") {
1,167✔
415
                        i++;
1,162✔
416
                    }
1,162✔
417
                } else if (token !== ";") {
7,371✔
418
                    // Check if this is a node
419
                    const nodeId = this.unquoteId(token);
6,109✔
420
                    i++;
6,109✔
421

422
                    // Parse node attributes
423
                    let attributes: Record<string, string | number> = {};
6,109✔
424
                    if (tokens[i] === "[") {
6,109✔
425
                        i++;
1,084✔
426
                        const attrs = this.parseAttributes(tokens, i);
1,084✔
427
                        ({ attributes, index: i } = attrs);
1,084✔
428
                    }
1,084✔
429

430
                    // Add or update node
431
                    if (nodes.has(nodeId)) {
6,109!
432
                        const existingNode = nodes.get(nodeId);
2✔
433
                        if (existingNode) {
2✔
434
                            Object.assign(existingNode.attributes, attributes);
2✔
435
                        }
2✔
436
                    } else {
6,109✔
437
                        nodes.set(nodeId, { id: nodeId, attributes });
6,107✔
438
                    }
6,107✔
439

440
                    // Skip semicolon if present
441
                    if (tokens[i] === ";") {
6,109✔
442
                        i++;
6,100✔
443
                    }
6,100✔
444
                } else {
6,109!
445
                    // Semicolon
446
                    i++;
×
447
                }
×
448
            } catch (error) {
7,371!
449
                // Error recovery: log error and try to skip to next statement
450
                const canContinue = this.errorAggregator.addError({
×
451
                    message: `Failed to parse DOT statement at token ${statementStartIndex} ("${token}"): ${error instanceof Error ? error.message : String(error)}`,
×
452
                    category: "parse-error",
×
453
                });
×
454

455
                if (!canContinue) {
×
456
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
457
                }
×
458

459
                // Skip to the next semicolon or closing brace to recover
460
                i = this.skipToNextStatement(tokens, i);
×
461
            }
×
462
        }
7,371✔
463

464
        return {
28✔
465
            nodes: Array.from(nodes.values()),
28✔
466
            edges,
28✔
467
        };
28✔
468
    }
28✔
469

470
    /**
471
     * Skips tokens until the next statement boundary (semicolon or closing brace).
472
     * Used for error recovery to continue parsing after a malformed statement.
473
     * @param tokens - Array of tokenized DOT content
474
     * @param startIndex - Index to start skipping from
475
     * @returns Index of the next statement boundary
476
     */
477
    private skipToNextStatement(tokens: string[], startIndex: number): number {
18✔
478
        let i = startIndex;
×
479
        let braceDepth = 0;
×
480

481
        while (i < tokens.length) {
×
482
            const token = tokens[i];
×
483

484
            if (token === "{") {
×
485
                braceDepth++;
×
486
            } else if (token === "}") {
×
487
                if (braceDepth > 0) {
×
488
                    braceDepth--;
×
489
                } else {
×
490
                    // Found a closing brace at the current level - don't consume it
491
                    return i;
×
492
                }
×
493
            } else if (token === ";" && braceDepth === 0) {
×
494
                // Found semicolon at current level - skip it and return
495
                return i + 1;
×
496
            }
×
497

498
            i++;
×
499
        }
×
500

501
        return i;
×
502
    }
×
503

504
    /**
505
     * Finds the index of the matching closing brace for an opening brace at startIndex.
506
     * Returns -1 if no matching brace is found.
507
     * @param tokens - Array of tokenized DOT content
508
     * @param startIndex - Index of the opening brace
509
     * @returns Index of matching closing brace or -1 if not found
510
     */
511
    private findMatchingBrace(tokens: string[], startIndex: number): number {
18✔
512
        if (tokens[startIndex] !== "{") {
11!
513
            return -1;
×
514
        }
×
515

516
        let depth = 0;
11✔
517
        for (let i = startIndex; i < tokens.length; i++) {
11✔
518
            if (tokens[i] === "{") {
228✔
519
                depth++;
13✔
520
            } else if (tokens[i] === "}") {
228✔
521
                depth--;
13✔
522
                if (depth === 0) {
13✔
523
                    return i;
11✔
524
                }
11✔
525
            }
13✔
526
        }
228!
527

528
        return -1;
×
529
    }
11✔
530

531
    /**
532
     * Collects all nodes in an edge chain, handling anonymous subgraphs.
533
     * For example:
534
     * - "a -> b -> c" returns [[a], [b], [c]]
535
     * - "{A B} -> {C D}" returns [[A, B], [C, D]]
536
     * - "a -> {B C} -> d" returns [[a], [B, C], [d]]
537
     * @param firstToken - The first node ID in the chain
538
     * @param tokens - Array of tokenized DOT content
539
     * @param operatorIndex - Index of the first edge operator
540
     * @returns Object containing node groups, attributes, and end index
541
     */
542
    private collectEdgeChainNodes(
18✔
543
        firstToken: string,
1,167✔
544
        tokens: string[],
1,167✔
545
        operatorIndex: number,
1,167✔
546
    ): { nodes: string[][]; attributes: Record<string, string | number>; endIndex: number } {
1,167✔
547
        // Start with the first token as a single-node group
548
        const firstGroup = [this.unquoteId(firstToken)];
1,167✔
549
        return this.collectEdgeChainNodesFromGroups([firstGroup], tokens, operatorIndex);
1,167✔
550
    }
1,167✔
551

552
    /**
553
     * Continues collecting edge chain nodes from an initial set of groups.
554
     * Used when the first part of the chain is an anonymous subgraph.
555
     * @param initialGroups - Initial node groups to start with
556
     * @param tokens - Array of tokenized DOT content
557
     * @param operatorIndex - Index of the first edge operator
558
     * @returns Object containing node groups, attributes, and end index
559
     */
560
    private collectEdgeChainNodesFromGroups(
18✔
561
        initialGroups: string[][],
1,167✔
562
        tokens: string[],
1,167✔
563
        operatorIndex: number,
1,167✔
564
    ): { nodes: string[][]; attributes: Record<string, string | number>; endIndex: number } {
1,167✔
565
        const nodeGroups: string[][] = [...initialGroups];
1,167✔
566
        let i = operatorIndex;
1,167✔
567

568
        // Parse the edge chain: -> node1 -> node2 -> ...
569
        while (i < tokens.length && (tokens[i] === "->" || tokens[i] === "--")) {
1,167✔
570
            i++; // Skip the edge operator
1,171✔
571

572
            // Check if next element is an anonymous subgraph
573
            if (tokens[i] === "{") {
1,171!
574
                // Collect nodes from anonymous subgraph
575
                const subgraphNodes = this.collectSubgraphNodes(tokens, i);
×
576
                nodeGroups.push(subgraphNodes.nodes);
×
577
                i = subgraphNodes.endIndex;
×
578
            } else {
1,171✔
579
                // Single node
580
                nodeGroups.push([this.unquoteId(tokens[i])]);
1,171✔
581
                i++;
1,171✔
582
            }
1,171✔
583
        }
1,171✔
584

585
        // Parse edge attributes if present
586
        const attributes: Record<string, string | number> = {};
1,167✔
587
        if (tokens[i] === "[") {
1,167✔
588
            i++; // Skip '['
1,128✔
589
            const attrs = this.parseAttributes(tokens, i);
1,128✔
590
            Object.assign(attributes, attrs.attributes);
1,128✔
591
            i = attrs.index;
1,128✔
592
        }
1,128✔
593

594
        return { nodes: nodeGroups, attributes, endIndex: i };
1,167✔
595
    }
1,167✔
596

597
    /**
598
     * Collects node IDs from an anonymous subgraph like { A B C }
599
     * Used for edge shorthand like {A B} -> {C D}
600
     * @param tokens - Array of tokenized DOT content
601
     * @param startIndex - Index of the opening brace
602
     * @returns Object containing node IDs and end index
603
     */
604
    private collectSubgraphNodes(tokens: string[], startIndex: number): { nodes: string[]; endIndex: number } {
18✔
605
        const nodes: string[] = [];
×
606
        let i = startIndex;
×
607

608
        if (tokens[i] !== "{") {
×
609
            return { nodes, endIndex: i };
×
610
        }
×
611

612
        i++; // Skip '{'
×
613
        let braceDepth = 1;
×
614

615
        while (i < tokens.length && braceDepth > 0) {
×
616
            const token = tokens[i];
×
617

618
            if (token === "{") {
×
619
                braceDepth++;
×
620
                i++;
×
621
                continue;
×
622
            }
×
623

624
            if (token === "}") {
×
625
                braceDepth--;
×
626
                i++;
×
627
                continue;
×
628
            }
×
629

630
            // Skip structural tokens and keywords
631
            if (token === ";" || token === "," || /^(subgraph|node|edge|graph)$/i.test(token)) {
×
632
                i++;
×
633
                continue;
×
634
            }
×
635

636
            // Skip attribute lists
637
            if (token === "[") {
×
638
                i++;
×
639
                const attrs = this.parseAttributes(tokens, i);
×
640
                i = attrs.index;
×
641
                continue;
×
642
            }
×
643

644
            // Skip edge operators and their targets within subgraph
645
            if (tokens[i + 1] === "->" || tokens[i + 1] === "--") {
×
646
                // This is an edge within the subgraph, skip it for now
647
                // We're only collecting top-level node IDs for the edge shorthand
648
                i++;
×
649
                continue;
×
650
            }
×
651

652
            // Skip assignment statements (like label = "foo")
653
            if (tokens[i + 1] === "=") {
×
654
                i += 3; // Skip identifier, =, value
×
655
                continue;
×
656
            }
×
657

658
            // This should be a node ID
659
            const nodeId = this.unquoteId(token);
×
660
            if (nodeId && !nodes.includes(nodeId)) {
×
661
                nodes.push(nodeId);
×
662
            }
×
663

664
            i++;
×
665
        }
×
666

667
        return { nodes, endIndex: i };
×
668
    }
×
669

670
    private parseAttributes(
18✔
671
        tokens: string[],
2,230✔
672
        startIndex: number,
2,230✔
673
    ): { attributes: Record<string, string | number>; index: number } {
2,230✔
674
        const attributes: Record<string, string | number> = {};
2,230✔
675
        let i = startIndex;
2,230✔
676

677
        while (i < tokens.length && tokens[i] !== "]") {
2,230✔
678
            const key = tokens[i];
7,031✔
679

680
            if (key === "," || key === ";") {
7,031!
681
                i++;
2✔
682
                continue;
2✔
683
            }
2✔
684

685
            i++;
7,029✔
686

687
            // Expect '='
688
            if (tokens[i] !== "=") {
7,031!
689
                i++;
10✔
690
                continue;
10✔
691
            }
10✔
692

693
            i++; // Skip '='
7,019✔
694

695
            // Get value
696
            const value = tokens[i];
7,019✔
697
            attributes[key] = this.parseValue(value);
7,019✔
698

699
            i++;
7,019✔
700

701
            // Skip comma or semicolon
702
            if (tokens[i] === "," || tokens[i] === ";") {
7,031✔
703
                i++;
4,777✔
704
            }
4,777✔
705
        }
7,031✔
706

707
        if (tokens[i] === "]") {
2,230✔
708
            i++; // Skip ']'
2,227✔
709
        }
2,227✔
710

711
        return { attributes, index: i };
2,230✔
712
    }
2,230✔
713

714
    private unquoteId(id: string): string {
18✔
715
        // Remove quotes if present
716
        let result = id;
8,447✔
717
        if (result.startsWith('"') && result.endsWith('"')) {
8,447!
718
            result = result.slice(1, -1);
×
719
        }
×
720

721
        // Strip port syntax: node:port or node:port:compass
722
        // Port syntax is only meaningful in edge statements, we just need the node ID
723
        const colonIndex = result.indexOf(":");
8,447✔
724
        if (colonIndex > 0) {
8,447!
725
            result = result.substring(0, colonIndex);
34✔
726
        }
34✔
727

728
        return result;
8,447✔
729
    }
8,447✔
730

731
    private parseValue(value: string): string | number {
18✔
732
        // Remove quotes if present
733
        if (value.startsWith('"') && value.endsWith('"')) {
7,019!
734
            return value.slice(1, -1);
×
735
        }
×
736

737
        // Try to parse as number
738
        if (/^-?\d+$/.test(value)) {
7,019✔
739
            return parseInt(value, 10);
52✔
740
        }
52✔
741

742
        if (/^-?\d+\.\d+$/.test(value)) {
7,019✔
743
            return parseFloat(value);
9✔
744
        }
9✔
745

746
        return value;
6,958✔
747
    }
7,019✔
748
}
18✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc