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

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

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

72.71
/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 (portEnd < content.length &&
34✔
151
                               !/[\s{}[\];,=]/.test(content[portEnd]) &&
102✔
152
                               content[portEnd] !== "\"" &&
68✔
153
                               !(content[portEnd] === "-" && portEnd + 1 < content.length &&
68!
154
                                 (content[portEnd + 1] === ">" || content[portEnd + 1] === "-"))) {
34✔
155
                            portEnd++;
68✔
156
                        }
68✔
157

158
                        current += `:${content.substring(i + 2, portEnd)}`;
34✔
159
                        i = portEnd - 1; // -1 because loop will increment
34✔
160
                    }
34✔
161

162
                    tokens.push(current);
3,613✔
163
                    current = "";
3,613✔
164
                    inString = false;
3,613✔
165
                } else {
7,189✔
166
                    if (current.trim()) {
3,614!
167
                        tokens.push(current.trim());
×
168
                        current = "";
×
169
                    }
×
170

171
                    inString = true;
3,614✔
172
                }
3,614✔
173

174
                continue;
7,227✔
175
            }
7,227✔
176

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

188
                current += char;
19,532✔
189
                continue;
19,532✔
190
            }
19,532✔
191

192
            // Handle structural characters
193
            if (["{", "}", "[", "]", ";", ",", "="].includes(char)) {
177,891✔
194
                if (current.trim()) {
23,641✔
195
                    tokens.push(current.trim());
17,644✔
196
                    current = "";
17,644✔
197
                }
17,644✔
198

199
                tokens.push(char);
23,641✔
200
                continue;
23,641✔
201
            }
23,641✔
202

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

213
                        tokens.push(char + next);
1,173✔
214
                        i++;
1,173✔
215
                        continue;
1,173✔
216
                    }
1,173✔
217
                }
1,173✔
218
            }
1,173✔
219

220
            // Handle whitespace
221
            if (/\s/.test(char)) {
177,891✔
222
                if (current.trim()) {
29,928✔
223
                    tokens.push(current.trim());
1,377✔
224
                    current = "";
1,377✔
225
                }
1,377✔
226

227
                continue;
29,928✔
228
            }
29,928✔
229

230
            current += char;
96,383✔
231
        }
96,383✔
232

233
        if (current.trim()) {
28!
234
            tokens.push(current.trim());
1✔
235
        }
1✔
236

237
        return tokens;
28✔
238
    }
28✔
239

240
    private parseTokens(tokens: string[]): {nodes: DOTNode[], edges: DOTEdge[]} {
18✔
241
        const nodes = new Map<string, DOTNode>();
28✔
242
        const edges: DOTEdge[] = [];
28✔
243
        let i = 0;
28✔
244

245
        // Skip graph type and optional name
246
        while (i < tokens.length && !(/^(strict|graph|digraph)$/i.exec(tokens[i]))) {
28!
247
            i++;
18✔
248
        }
18✔
249

250
        if (/^strict$/i.exec(tokens[i])) {
28!
251
            i++; // Skip 'strict'
1✔
252
        }
1✔
253

254
        if (/^(graph|digraph)$/i.exec(tokens[i])) {
28✔
255
            i++; // Skip 'graph' or 'digraph'
27✔
256
        }
27✔
257

258
        // Skip optional graph name
259
        if (tokens[i] && tokens[i] !== "{") {
28✔
260
            i++; // Skip graph name
14✔
261
        }
14✔
262

263
        // Find opening brace
264
        while (i < tokens.length && tokens[i] !== "{") {
28!
265
            i++;
×
266
        }
×
267
        i++; // Skip '{'
28✔
268

269
        // Parse graph contents (track brace depth for subgraphs)
270
        let braceDepth = 1;
28✔
271
        while (i < tokens.length && braceDepth > 0) {
28✔
272
            const token = tokens[i];
7,371✔
273
            const statementStartIndex = i;
7,371✔
274

275
            try {
7,371✔
276
                // Handle closing brace
277
                if (token === "}") {
7,371✔
278
                    braceDepth--;
33✔
279
                    i++;
33✔
280
                    continue;
33✔
281
                }
33✔
282

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

299
                        // Create edges between consecutive nodes in the chain
300
                        for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
×
301
                            const srcNodes = chainNodes.nodes[j];
×
302
                            const dstNodes = chainNodes.nodes[j + 1];
×
303

304
                            // Cartesian product: each src connects to each dst
305
                            for (const src of srcNodes) {
×
306
                                for (const dst of dstNodes) {
×
307
                                    // Ensure nodes exist
308
                                    if (!nodes.has(src)) {
×
309
                                        nodes.set(src, {id: src, attributes: {}});
×
310
                                    }
×
311

312
                                    if (!nodes.has(dst)) {
×
313
                                        nodes.set(dst, {id: dst, attributes: {}});
×
314
                                    }
×
315

316
                                    edges.push({src, dst, attributes: chainNodes.attributes});
×
317
                                }
×
318
                            }
×
319
                        }
×
320

321
                        i = chainNodes.endIndex;
×
322

323
                        // Skip semicolon if present
324
                        if (tokens[i] === ";") {
×
325
                            i++;
×
326
                        }
×
327

328
                        continue;
×
329
                    }
×
330

331
                    // Not an edge statement, just a regular subgraph - continue normal processing
332
                    braceDepth++;
11✔
333
                    i++;
11✔
334
                    continue;
11✔
335
                }
11✔
336

337
                // Skip subgraph keyword (but continue parsing contents normally)
338
                if (/^subgraph$/i.exec(token)) {
7,371✔
339
                    i++;
11✔
340
                    // Skip optional subgraph name
341
                    if (tokens[i] && tokens[i] !== "{" && tokens[i] !== ";") {
11✔
342
                        i++;
11✔
343
                    }
11✔
344

345
                    continue;
11✔
346
                }
11✔
347

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

352
                    // Skip optional semicolon
353
                    if (tokens[i] === ";") {
22✔
354
                        i++;
14✔
355
                    }
14✔
356

357
                    continue;
22✔
358
                }
22✔
359

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

369
                    // Skip optional semicolon
370
                    if (tokens[i] === ";") {
18✔
371
                        i++;
8✔
372
                    }
8✔
373

374
                    continue;
18✔
375
                }
18✔
376

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

382
                    // Create edges between consecutive nodes in the chain
383
                    for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
1,167✔
384
                        const srcNodes = chainNodes.nodes[j];
1,171✔
385
                        const dstNodes = chainNodes.nodes[j + 1];
1,171✔
386

387
                        // Cartesian product: each src connects to each dst
388
                        for (const src of srcNodes) {
1,171✔
389
                            for (const dst of dstNodes) {
1,171✔
390
                                // Ensure nodes exist
391
                                if (!nodes.has(src)) {
1,171!
392
                                    nodes.set(src, {id: src, attributes: {}});
17✔
393
                                }
17✔
394

395
                                if (!nodes.has(dst)) {
1,171!
396
                                    nodes.set(dst, {id: dst, attributes: {}});
31✔
397
                                }
31✔
398

399
                                edges.push({src, dst, attributes: chainNodes.attributes});
1,171✔
400
                            }
1,171✔
401
                        }
1,171✔
402
                    }
1,171✔
403

404
                    i = chainNodes.endIndex;
1,167✔
405

406
                    // Skip semicolon if present
407
                    if (tokens[i] === ";") {
1,167✔
408
                        i++;
1,162✔
409
                    }
1,162✔
410
                } else if (token !== ";") {
7,371✔
411
                    // Check if this is a node
412
                    const nodeId = this.unquoteId(token);
6,109✔
413
                    i++;
6,109✔
414

415
                    // Parse node attributes
416
                    let attributes: Record<string, string | number> = {};
6,109✔
417
                    if (tokens[i] === "[") {
6,109✔
418
                        i++;
1,084✔
419
                        const attrs = this.parseAttributes(tokens, i);
1,084✔
420
                        ({attributes, index: i} = attrs);
1,084✔
421
                    }
1,084✔
422

423
                    // Add or update node
424
                    if (nodes.has(nodeId)) {
6,109!
425
                        const existingNode = nodes.get(nodeId);
2✔
426
                        if (existingNode) {
2✔
427
                            Object.assign(existingNode.attributes, attributes);
2✔
428
                        }
2✔
429
                    } else {
6,109✔
430
                        nodes.set(nodeId, {id: nodeId, attributes});
6,107✔
431
                    }
6,107✔
432

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

448
                if (!canContinue) {
×
449
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
450
                }
×
451

452
                // Skip to the next semicolon or closing brace to recover
453
                i = this.skipToNextStatement(tokens, i);
×
454
            }
×
455
        }
7,371✔
456

457
        return {
28✔
458
            nodes: Array.from(nodes.values()),
28✔
459
            edges,
28✔
460
        };
28✔
461
    }
28✔
462

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

474
        while (i < tokens.length) {
×
475
            const token = tokens[i];
×
476

477
            if (token === "{") {
×
478
                braceDepth++;
×
479
            } else if (token === "}") {
×
480
                if (braceDepth > 0) {
×
481
                    braceDepth--;
×
482
                } else {
×
483
                    // Found a closing brace at the current level - don't consume it
484
                    return i;
×
485
                }
×
486
            } else if (token === ";" && braceDepth === 0) {
×
487
                // Found semicolon at current level - skip it and return
488
                return i + 1;
×
489
            }
×
490

491
            i++;
×
492
        }
×
493

494
        return i;
×
495
    }
×
496

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

509
        let depth = 0;
11✔
510
        for (let i = startIndex; i < tokens.length; i++) {
11✔
511
            if (tokens[i] === "{") {
228✔
512
                depth++;
13✔
513
            } else if (tokens[i] === "}") {
228✔
514
                depth--;
13✔
515
                if (depth === 0) {
13✔
516
                    return i;
11✔
517
                }
11✔
518
            }
13✔
519
        }
228!
520

521
        return -1;
×
522
    }
11✔
523

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

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

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

565
            // Check if next element is an anonymous subgraph
566
            if (tokens[i] === "{") {
1,171!
567
                // Collect nodes from anonymous subgraph
568
                const subgraphNodes = this.collectSubgraphNodes(tokens, i);
×
569
                nodeGroups.push(subgraphNodes.nodes);
×
570
                i = subgraphNodes.endIndex;
×
571
            } else {
1,171✔
572
                // Single node
573
                nodeGroups.push([this.unquoteId(tokens[i])]);
1,171✔
574
                i++;
1,171✔
575
            }
1,171✔
576
        }
1,171✔
577

578
        // Parse edge attributes if present
579
        const attributes: Record<string, string | number> = {};
1,167✔
580
        if (tokens[i] === "[") {
1,167✔
581
            i++; // Skip '['
1,128✔
582
            const attrs = this.parseAttributes(tokens, i);
1,128✔
583
            Object.assign(attributes, attrs.attributes);
1,128✔
584
            i = attrs.index;
1,128✔
585
        }
1,128✔
586

587
        return {nodes: nodeGroups, attributes, endIndex: i};
1,167✔
588
    }
1,167✔
589

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

604
        if (tokens[i] !== "{") {
×
605
            return {nodes, endIndex: i};
×
606
        }
×
607

608
        i++; // Skip '{'
×
609
        let braceDepth = 1;
×
610

611
        while (i < tokens.length && braceDepth > 0) {
×
612
            const token = tokens[i];
×
613

614
            if (token === "{") {
×
615
                braceDepth++;
×
616
                i++;
×
617
                continue;
×
618
            }
×
619

620
            if (token === "}") {
×
621
                braceDepth--;
×
622
                i++;
×
623
                continue;
×
624
            }
×
625

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

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

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

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

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

661
            i++;
×
662
        }
×
663

664
        return {nodes, endIndex: i};
×
665
    }
×
666

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

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

677
            if (key === "," || key === ";") {
7,031!
678
                i++;
2✔
679
                continue;
2✔
680
            }
2✔
681

682
            i++;
7,029✔
683

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

690
            i++; // Skip '='
7,019✔
691

692
            // Get value
693
            const value = tokens[i];
7,019✔
694
            attributes[key] = this.parseValue(value);
7,019✔
695

696
            i++;
7,019✔
697

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

704
        if (tokens[i] === "]") {
2,230✔
705
            i++; // Skip ']'
2,227✔
706
        }
2,227✔
707

708
        return {attributes, index: i};
2,230✔
709
    }
2,230✔
710

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

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

725
        return result;
8,447✔
726
    }
8,447✔
727

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

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

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

743
        return value;
6,958✔
744
    }
7,019✔
745
}
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

© 2025 Coveralls, Inc