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

graphty-org / graphty-element / 20219217725

15 Dec 2025 03:10AM UTC coverage: 83.666% (-2.7%) from 86.405%
20219217725

push

github

apowers313
chore: delint

4072 of 4771 branches covered (85.35%)

Branch coverage included in aggregate %.

15 of 26 new or added lines in 1 file covered. (57.69%)

890 existing lines in 12 files now uncovered.

18814 of 22583 relevant lines covered (83.31%)

8168.81 hits per line

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

74.47
/src/data/DOTDataSource.ts
1
import type {AdHocData} from "../config/common.js";
2
import {BaseDataSourceConfig, DataSource, DataSourceChunk} from "./DataSource.js";
3✔
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
export class DOTDataSource extends DataSource {
3✔
19
    static readonly type = "dot";
6✔
20

21
    private config: DOTDataSourceConfig;
22

23
    constructor(config: DOTDataSourceConfig) {
6✔
24
        super(config.errorLimit ?? 100, config.chunkSize);
30✔
25
        this.config = config;
30✔
26
    }
30✔
27

28
    protected getConfig(): BaseDataSourceConfig {
6✔
29
        return this.config;
30✔
30
    }
30✔
31

32
    async *sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown> {
6✔
33
    // Get DOT content
34
        const dotContent = await this.getContent();
30✔
35

36
        // Parse DOT
37
        const {nodes, edges} = this.parseDOT(dotContent);
29✔
38

39
        // Use shared chunking helper
40
        yield* this.chunkData(nodes, edges);
29✔
41
    }
30✔
42

43
    private parseDOT(content: string): {nodes: AdHocData[], edges: AdHocData[]} {
6✔
44
        // Handle empty content gracefully
45
        if (content.trim() === "") {
29✔
46
            return {nodes: [], edges: []};
1✔
47
        }
1✔
48

49
        // Remove comments
50
        let cleaned = content.replace(/\/\/.*$/gm, ""); // Single-line comments
28✔
51
        cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, ""); // Multi-line comments
28✔
52

53
        // Tokenize with error handling
54
        let tokens: string[];
28✔
55
        try {
28✔
56
            tokens = this.tokenize(cleaned);
28✔
57
        } catch (error) {
29!
UNCOV
58
            const canContinue = this.errorAggregator.addError({
×
UNCOV
59
                message: `Failed to tokenize DOT content: ${error instanceof Error ? error.message : String(error)}`,
×
UNCOV
60
                category: "parse-error",
×
UNCOV
61
            });
×
62

UNCOV
63
            if (!canContinue) {
×
UNCOV
64
                throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
UNCOV
65
            }
×
66

UNCOV
67
            return {nodes: [], edges: []};
×
UNCOV
68
        }
✔
69

70
        // Handle empty token list
71
        if (tokens.length === 0) {
29!
UNCOV
72
            return {nodes: [], edges: []};
×
UNCOV
73
        }
✔
74

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

78
        // Convert to AdHocData
79
        const nodes = parsedNodes.map((node) => ({
28✔
80
            id: node.id,
6,155✔
81
            ... node.attributes,
6,155✔
82
        })) as unknown as AdHocData[];
28✔
83

84
        const edges = parsedEdges.map((edge) => ({
28✔
85
            src: edge.src,
1,171✔
86
            dst: edge.dst,
1,171✔
87
            ... edge.attributes,
1,171✔
88
        })) as unknown as AdHocData[];
28✔
89

90
        return {nodes, edges};
28✔
91
    }
29✔
92

93
    private tokenize(content: string): string[] {
6✔
94
        const tokens: string[] = [];
28✔
95
        let current = "";
28✔
96
        let inString = false;
28✔
97
        let inHtmlLabel = false;
28✔
98
        let htmlDepth = 0;
28✔
99

100
        for (let i = 0; i < content.length; i++) {
28✔
101
            const char = content[i];
177,891✔
102

103
            // Handle HTML-like labels
104
            if (!inString && char === "<" && content[i + 1] !== "<") {
177,891✔
105
                inHtmlLabel = true;
2✔
106
                htmlDepth++;
2✔
107
                current += char;
2✔
108
                continue;
2✔
109
            }
2✔
110

111
            if (inHtmlLabel && char === ">") {
177,891✔
112
                current += char;
2✔
113
                htmlDepth--;
2✔
114
                if (htmlDepth === 0) {
2✔
115
                    inHtmlLabel = false;
2✔
116
                }
2✔
117

118
                continue;
2✔
119
            }
2✔
120

121
            if (inHtmlLabel) {
177,891✔
122
                current += char;
3✔
123
                if (char === "<") {
3!
UNCOV
124
                    htmlDepth++;
×
UNCOV
125
                }
×
126

127
                continue;
3✔
128
            }
3✔
129

130
            // Handle quoted strings
131
            if (char === "\"" && (i === 0 || content[i - 1] !== "\\")) {
177,891✔
132
                if (inString) {
7,227✔
133
                    // End of quoted string - check if followed by port syntax (:port or :port:compass)
134
                    // If so, include the port as part of this token
135
                    if (i + 1 < content.length && content[i + 1] === ":") {
3,613✔
136
                        // Continue collecting the port suffix
137
                        let portEnd = i + 2;
34✔
138
                        while (portEnd < content.length &&
34✔
139
                               !/[\s{}[\];,=]/.test(content[portEnd]) &&
102✔
140
                               content[portEnd] !== "\"" &&
68✔
141
                               !(content[portEnd] === "-" && portEnd + 1 < content.length &&
68!
142
                                 (content[portEnd + 1] === ">" || content[portEnd + 1] === "-"))) {
34✔
143
                            portEnd++;
68✔
144
                        }
68✔
145

146
                        current += `:${content.substring(i + 2, portEnd)}`;
34✔
147
                        i = portEnd - 1; // -1 because loop will increment
34✔
148
                    }
34✔
149

150
                    tokens.push(current);
3,613✔
151
                    current = "";
3,613✔
152
                    inString = false;
3,613✔
153
                } else {
3,626✔
154
                    if (current.trim()) {
3,614!
UNCOV
155
                        tokens.push(current.trim());
×
156
                        current = "";
×
157
                    }
×
158

159
                    inString = true;
3,614✔
160
                }
3,614✔
161

162
                continue;
7,227✔
163
            }
7,227✔
164

165
            if (inString) {
177,891✔
166
                // Handle escape sequences
167
                if (char === "\\" && i + 1 < content.length) {
19,532✔
168
                    const next = content[i + 1];
3✔
169
                    if (next === "\"" || next === "\\") {
3!
UNCOV
170
                        current += next;
×
UNCOV
171
                        i++;
×
UNCOV
172
                        continue;
×
UNCOV
173
                    }
×
174
                }
3✔
175

176
                current += char;
19,532✔
177
                continue;
19,532✔
178
            }
19,532✔
179

180
            // Handle structural characters
181
            if (["{", "}", "[", "]", ";", ",", "="].includes(char)) {
177,891✔
182
                if (current.trim()) {
23,641✔
183
                    tokens.push(current.trim());
17,644✔
184
                    current = "";
17,644✔
185
                }
17,644✔
186

187
                tokens.push(char);
23,641✔
188
                continue;
23,641✔
189
            }
23,641✔
190

191
            // Handle edge operators
192
            if (char === "-") {
177,891✔
193
                if (i + 1 < content.length) {
1,173✔
194
                    const next = content[i + 1];
1,173✔
195
                    if (next === "-" || next === ">") {
1,173✔
196
                        if (current.trim()) {
1,173✔
197
                            tokens.push(current.trim());
1✔
198
                            current = "";
1✔
199
                        }
1✔
200

201
                        tokens.push(char + next);
1,173✔
202
                        i++;
1,173✔
203
                        continue;
1,173✔
204
                    }
1,173✔
205
                }
1,173✔
206
            }
1,173✔
207

208
            // Handle whitespace
209
            if (/\s/.test(char)) {
177,891✔
210
                if (current.trim()) {
29,928✔
211
                    tokens.push(current.trim());
1,377✔
212
                    current = "";
1,377✔
213
                }
1,377✔
214

215
                continue;
29,928✔
216
            }
29,928✔
217

218
            current += char;
96,383✔
219
        }
96,383✔
220

221
        if (current.trim()) {
28✔
222
            tokens.push(current.trim());
1✔
223
        }
1✔
224

225
        return tokens;
28✔
226
    }
28✔
227

228
    private parseTokens(tokens: string[]): {nodes: DOTNode[], edges: DOTEdge[]} {
6✔
229
        const nodes = new Map<string, DOTNode>();
28✔
230
        const edges: DOTEdge[] = [];
28✔
231
        let i = 0;
28✔
232

233
        // Skip graph type and optional name
234
        while (i < tokens.length && !(/^(strict|graph|digraph)$/i.exec(tokens[i]))) {
28✔
235
            i++;
18✔
236
        }
18✔
237

238
        if (/^strict$/i.exec(tokens[i])) {
28✔
239
            i++; // Skip 'strict'
1✔
240
        }
1✔
241

242
        if (/^(graph|digraph)$/i.exec(tokens[i])) {
28✔
243
            i++; // Skip 'graph' or 'digraph'
27✔
244
        }
27✔
245

246
        // Skip optional graph name
247
        if (tokens[i] && tokens[i] !== "{") {
28✔
248
            i++; // Skip graph name
14✔
249
        }
14✔
250

251
        // Find opening brace
252
        while (i < tokens.length && tokens[i] !== "{") {
28!
UNCOV
253
            i++;
×
UNCOV
254
        }
×
255
        i++; // Skip '{'
28✔
256

257
        // Parse graph contents (track brace depth for subgraphs)
258
        let braceDepth = 1;
28✔
259
        while (i < tokens.length && braceDepth > 0) {
28✔
260
            const token = tokens[i];
7,371✔
261
            const statementStartIndex = i;
7,371✔
262

263
            try {
7,371✔
264
                // Handle closing brace
265
                if (token === "}") {
7,371✔
266
                    braceDepth--;
33✔
267
                    i++;
33✔
268
                    continue;
33✔
269
                }
33✔
270

271
                // Check if this is an anonymous subgraph starting an edge statement like {A B} -> C
272
                // MUST check this BEFORE the generic brace handling
273
                if (token === "{") {
7,371✔
274
                    // Look ahead to see if there's an edge operator after the closing brace
275
                    const closingIndex = this.findMatchingBrace(tokens, i);
11✔
276
                    if (closingIndex !== -1 &&
11✔
277
                        closingIndex + 1 < tokens.length &&
11✔
278
                        (tokens[closingIndex + 1] === "->" || tokens[closingIndex + 1] === "--")) {
11!
279
                        // This is an edge statement starting with an anonymous subgraph
UNCOV
280
                        const firstNodes = this.collectSubgraphNodes(tokens, i);
×
UNCOV
281
                        const chainNodes = this.collectEdgeChainNodesFromGroups(
×
UNCOV
282
                            [firstNodes.nodes],
×
UNCOV
283
                            tokens,
×
UNCOV
284
                            firstNodes.endIndex,
×
UNCOV
285
                        );
×
286

287
                        // Create edges between consecutive nodes in the chain
UNCOV
288
                        for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
×
UNCOV
289
                            const srcNodes = chainNodes.nodes[j];
×
UNCOV
290
                            const dstNodes = chainNodes.nodes[j + 1];
×
291

292
                            // Cartesian product: each src connects to each dst
UNCOV
293
                            for (const src of srcNodes) {
×
UNCOV
294
                                for (const dst of dstNodes) {
×
295
                                    // Ensure nodes exist
UNCOV
296
                                    if (!nodes.has(src)) {
×
UNCOV
297
                                        nodes.set(src, {id: src, attributes: {}});
×
UNCOV
298
                                    }
×
299

UNCOV
300
                                    if (!nodes.has(dst)) {
×
UNCOV
301
                                        nodes.set(dst, {id: dst, attributes: {}});
×
UNCOV
302
                                    }
×
303

304
                                    edges.push({src, dst, attributes: chainNodes.attributes});
×
305
                                }
×
306
                            }
×
307
                        }
×
308

UNCOV
309
                        i = chainNodes.endIndex;
×
310

311
                        // Skip semicolon if present
UNCOV
312
                        if (tokens[i] === ";") {
×
UNCOV
313
                            i++;
×
UNCOV
314
                        }
×
315

UNCOV
316
                        continue;
×
UNCOV
317
                    }
×
318

319
                    // Not an edge statement, just a regular subgraph - continue normal processing
320
                    braceDepth++;
11✔
321
                    i++;
11✔
322
                    continue;
11✔
323
                }
11✔
324

325
                // Skip subgraph keyword (but continue parsing contents normally)
326
                if (/^subgraph$/i.exec(token)) {
7,371✔
327
                    i++;
11✔
328
                    // Skip optional subgraph name
329
                    if (tokens[i] && tokens[i] !== "{" && tokens[i] !== ";") {
11✔
330
                        i++;
11✔
331
                    }
11✔
332

333
                    continue;
11✔
334
                }
11✔
335

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

340
                    // Skip optional semicolon
341
                    if (tokens[i] === ";") {
22✔
342
                        i++;
14✔
343
                    }
14✔
344

345
                    continue;
22✔
346
                }
22✔
347

348
                // Handle 'node', 'edge', 'graph' keywords followed by '[' (default attribute statements)
349
                // These are NOT node declarations - they set default attributes for subsequent elements
350
                if (/^(node|edge|graph)$/i.test(token) && tokens[i + 1] === "[") {
7,371✔
351
                    i++; // Skip the keyword
18✔
352
                    i++; // Skip '['
18✔
353
                    // Skip the attributes - we don't apply defaults (just structure parsing)
354
                    const attrs = this.parseAttributes(tokens, i);
18✔
355
                    i = attrs.index;
18✔
356

357
                    // Skip optional semicolon
358
                    if (tokens[i] === ";") {
18✔
359
                        i++;
8✔
360
                    }
8✔
361

362
                    continue;
18✔
363
                }
18✔
364

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

370
                    // Create edges between consecutive nodes in the chain
371
                    for (let j = 0; j < chainNodes.nodes.length - 1; j++) {
1,167✔
372
                        const srcNodes = chainNodes.nodes[j];
1,171✔
373
                        const dstNodes = chainNodes.nodes[j + 1];
1,171✔
374

375
                        // Cartesian product: each src connects to each dst
376
                        for (const src of srcNodes) {
1,171✔
377
                            for (const dst of dstNodes) {
1,171✔
378
                                // Ensure nodes exist
379
                                if (!nodes.has(src)) {
1,171✔
380
                                    nodes.set(src, {id: src, attributes: {}});
17✔
381
                                }
17✔
382

383
                                if (!nodes.has(dst)) {
1,171✔
384
                                    nodes.set(dst, {id: dst, attributes: {}});
31✔
385
                                }
31✔
386

387
                                edges.push({src, dst, attributes: chainNodes.attributes});
1,171✔
388
                            }
1,171✔
389
                        }
1,171✔
390
                    }
1,171✔
391

392
                    i = chainNodes.endIndex;
1,167✔
393

394
                    // Skip semicolon if present
395
                    if (tokens[i] === ";") {
1,167✔
396
                        i++;
1,162✔
397
                    }
1,162✔
398
                } else if (token !== ";") {
7,371✔
399
                    // Check if this is a node
400
                    const nodeId = this.unquoteId(token);
6,109✔
401
                    i++;
6,109✔
402

403
                    // Parse node attributes
404
                    let attributes: Record<string, string | number> = {};
6,109✔
405
                    if (tokens[i] === "[") {
6,109✔
406
                        i++;
1,084✔
407
                        const attrs = this.parseAttributes(tokens, i);
1,084✔
408
                        ({attributes, index: i} = attrs);
1,084✔
409
                    }
1,084✔
410

411
                    // Add or update node
412
                    if (nodes.has(nodeId)) {
6,109✔
413
                        const existingNode = nodes.get(nodeId);
2✔
414
                        if (existingNode) {
2✔
415
                            Object.assign(existingNode.attributes, attributes);
2✔
416
                        }
2✔
417
                    } else {
6,109✔
418
                        nodes.set(nodeId, {id: nodeId, attributes});
6,107✔
419
                    }
6,107✔
420

421
                    // Skip semicolon if present
422
                    if (tokens[i] === ";") {
6,109✔
423
                        i++;
6,100✔
424
                    }
6,100✔
425
                } else {
6,109!
426
                    // Semicolon
UNCOV
427
                    i++;
×
UNCOV
428
                }
×
429
            } catch (error) {
7,371!
430
                // Error recovery: log error and try to skip to next statement
UNCOV
431
                const canContinue = this.errorAggregator.addError({
×
UNCOV
432
                    message: `Failed to parse DOT statement at token ${statementStartIndex} ("${token}"): ${error instanceof Error ? error.message : String(error)}`,
×
UNCOV
433
                    category: "parse-error",
×
UNCOV
434
                });
×
435

UNCOV
436
                if (!canContinue) {
×
UNCOV
437
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
UNCOV
438
                }
×
439

440
                // Skip to the next semicolon or closing brace to recover
UNCOV
441
                i = this.skipToNextStatement(tokens, i);
×
UNCOV
442
            }
×
443
        }
7,371✔
444

445
        return {
28✔
446
            nodes: Array.from(nodes.values()),
28✔
447
            edges,
28✔
448
        };
28✔
449
    }
28✔
450

451
    /**
452
     * Skips tokens until the next statement boundary (semicolon or closing brace).
453
     * Used for error recovery to continue parsing after a malformed statement.
454
     */
455
    private skipToNextStatement(tokens: string[], startIndex: number): number {
6✔
UNCOV
456
        let i = startIndex;
×
UNCOV
457
        let braceDepth = 0;
×
458

UNCOV
459
        while (i < tokens.length) {
×
UNCOV
460
            const token = tokens[i];
×
461

UNCOV
462
            if (token === "{") {
×
UNCOV
463
                braceDepth++;
×
UNCOV
464
            } else if (token === "}") {
×
UNCOV
465
                if (braceDepth > 0) {
×
UNCOV
466
                    braceDepth--;
×
UNCOV
467
                } else {
×
468
                    // Found a closing brace at the current level - don't consume it
UNCOV
469
                    return i;
×
UNCOV
470
                }
×
UNCOV
471
            } else if (token === ";" && braceDepth === 0) {
×
472
                // Found semicolon at current level - skip it and return
UNCOV
473
                return i + 1;
×
UNCOV
474
            }
×
475

UNCOV
476
            i++;
×
UNCOV
477
        }
×
478

UNCOV
479
        return i;
×
UNCOV
480
    }
×
481

482
    /**
483
     * Finds the index of the matching closing brace for an opening brace at startIndex.
484
     * Returns -1 if no matching brace is found.
485
     */
486
    private findMatchingBrace(tokens: string[], startIndex: number): number {
6✔
487
        if (tokens[startIndex] !== "{") {
11!
UNCOV
488
            return -1;
×
UNCOV
489
        }
×
490

491
        let depth = 0;
11✔
492
        for (let i = startIndex; i < tokens.length; i++) {
11✔
493
            if (tokens[i] === "{") {
228✔
494
                depth++;
13✔
495
            } else if (tokens[i] === "}") {
228✔
496
                depth--;
13✔
497
                if (depth === 0) {
13✔
498
                    return i;
11✔
499
                }
11✔
500
            }
13✔
501
        }
228!
502

UNCOV
503
        return -1;
×
504
    }
11✔
505

506
    /**
507
     * Collects all nodes in an edge chain, handling anonymous subgraphs.
508
     * For example:
509
     * - "a -> b -> c" returns [[a], [b], [c]]
510
     * - "{A B} -> {C D}" returns [[A, B], [C, D]]
511
     * - "a -> {B C} -> d" returns [[a], [B, C], [d]]
512
     */
513
    private collectEdgeChainNodes(
6✔
514
        firstToken: string,
1,167✔
515
        tokens: string[],
1,167✔
516
        operatorIndex: number,
1,167✔
517
    ): {nodes: string[][], attributes: Record<string, string | number>, endIndex: number} {
1,167✔
518
        // Start with the first token as a single-node group
519
        const firstGroup = [this.unquoteId(firstToken)];
1,167✔
520
        return this.collectEdgeChainNodesFromGroups([firstGroup], tokens, operatorIndex);
1,167✔
521
    }
1,167✔
522

523
    /**
524
     * Continues collecting edge chain nodes from an initial set of groups.
525
     * Used when the first part of the chain is an anonymous subgraph.
526
     */
527
    private collectEdgeChainNodesFromGroups(
6✔
528
        initialGroups: string[][],
1,167✔
529
        tokens: string[],
1,167✔
530
        operatorIndex: number,
1,167✔
531
    ): {nodes: string[][], attributes: Record<string, string | number>, endIndex: number} {
1,167✔
532
        const nodeGroups: string[][] = [... initialGroups];
1,167✔
533
        let i = operatorIndex;
1,167✔
534

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

539
            // Check if next element is an anonymous subgraph
540
            if (tokens[i] === "{") {
1,171!
541
                // Collect nodes from anonymous subgraph
UNCOV
542
                const subgraphNodes = this.collectSubgraphNodes(tokens, i);
×
UNCOV
543
                nodeGroups.push(subgraphNodes.nodes);
×
UNCOV
544
                i = subgraphNodes.endIndex;
×
545
            } else {
1,171✔
546
                // Single node
547
                nodeGroups.push([this.unquoteId(tokens[i])]);
1,171✔
548
                i++;
1,171✔
549
            }
1,171✔
550
        }
1,171✔
551

552
        // Parse edge attributes if present
553
        const attributes: Record<string, string | number> = {};
1,167✔
554
        if (tokens[i] === "[") {
1,167✔
555
            i++; // Skip '['
1,128✔
556
            const attrs = this.parseAttributes(tokens, i);
1,128✔
557
            Object.assign(attributes, attrs.attributes);
1,128✔
558
            i = attrs.index;
1,128✔
559
        }
1,128✔
560

561
        return {nodes: nodeGroups, attributes, endIndex: i};
1,167✔
562
    }
1,167✔
563

564
    /**
565
     * Collects node IDs from an anonymous subgraph like { A B C }
566
     * Used for edge shorthand like {A B} -> {C D}
567
     */
568
    private collectSubgraphNodes(
6✔
UNCOV
569
        tokens: string[],
×
UNCOV
570
        startIndex: number,
×
UNCOV
571
    ): {nodes: string[], endIndex: number} {
×
UNCOV
572
        const nodes: string[] = [];
×
UNCOV
573
        let i = startIndex;
×
574

UNCOV
575
        if (tokens[i] !== "{") {
×
UNCOV
576
            return {nodes, endIndex: i};
×
UNCOV
577
        }
×
578

UNCOV
579
        i++; // Skip '{'
×
UNCOV
580
        let braceDepth = 1;
×
581

UNCOV
582
        while (i < tokens.length && braceDepth > 0) {
×
UNCOV
583
            const token = tokens[i];
×
584

UNCOV
585
            if (token === "{") {
×
UNCOV
586
                braceDepth++;
×
UNCOV
587
                i++;
×
UNCOV
588
                continue;
×
UNCOV
589
            }
×
590

UNCOV
591
            if (token === "}") {
×
UNCOV
592
                braceDepth--;
×
UNCOV
593
                i++;
×
UNCOV
594
                continue;
×
UNCOV
595
            }
×
596

597
            // Skip structural tokens and keywords
UNCOV
598
            if (token === ";" || token === "," ||
×
UNCOV
599
                /^(subgraph|node|edge|graph)$/i.test(token)) {
×
UNCOV
600
                i++;
×
UNCOV
601
                continue;
×
UNCOV
602
            }
×
603

604
            // Skip attribute lists
UNCOV
605
            if (token === "[") {
×
UNCOV
606
                i++;
×
UNCOV
607
                const attrs = this.parseAttributes(tokens, i);
×
UNCOV
608
                i = attrs.index;
×
UNCOV
609
                continue;
×
UNCOV
610
            }
×
611

612
            // Skip edge operators and their targets within subgraph
UNCOV
613
            if (tokens[i + 1] === "->" || tokens[i + 1] === "--") {
×
614
                // This is an edge within the subgraph, skip it for now
615
                // We're only collecting top-level node IDs for the edge shorthand
UNCOV
616
                i++;
×
UNCOV
617
                continue;
×
UNCOV
618
            }
×
619

620
            // Skip assignment statements (like label = "foo")
UNCOV
621
            if (tokens[i + 1] === "=") {
×
UNCOV
622
                i += 3; // Skip identifier, =, value
×
UNCOV
623
                continue;
×
UNCOV
624
            }
×
625

626
            // This should be a node ID
UNCOV
627
            const nodeId = this.unquoteId(token);
×
UNCOV
628
            if (nodeId && !nodes.includes(nodeId)) {
×
UNCOV
629
                nodes.push(nodeId);
×
UNCOV
630
            }
×
631

UNCOV
632
            i++;
×
UNCOV
633
        }
×
634

UNCOV
635
        return {nodes, endIndex: i};
×
UNCOV
636
    }
×
637

638
    private parseAttributes(
6✔
639
        tokens: string[],
2,230✔
640
        startIndex: number,
2,230✔
641
    ): {attributes: Record<string, string | number>, index: number} {
2,230✔
642
        const attributes: Record<string, string | number> = {};
2,230✔
643
        let i = startIndex;
2,230✔
644

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

648
            if (key === "," || key === ";") {
7,031✔
649
                i++;
2✔
650
                continue;
2✔
651
            }
2✔
652

653
            i++;
7,029✔
654

655
            // Expect '='
656
            if (tokens[i] !== "=") {
7,031✔
657
                i++;
10✔
658
                continue;
10✔
659
            }
10✔
660

661
            i++; // Skip '='
7,019✔
662

663
            // Get value
664
            const value = tokens[i];
7,019✔
665
            attributes[key] = this.parseValue(value);
7,019✔
666

667
            i++;
7,019✔
668

669
            // Skip comma or semicolon
670
            if (tokens[i] === "," || tokens[i] === ";") {
7,031✔
671
                i++;
4,777✔
672
            }
4,777✔
673
        }
7,031✔
674

675
        if (tokens[i] === "]") {
2,230✔
676
            i++; // Skip ']'
2,227✔
677
        }
2,227✔
678

679
        return {attributes, index: i};
2,230✔
680
    }
2,230✔
681

682
    private unquoteId(id: string): string {
6✔
683
        // Remove quotes if present
684
        let result = id;
8,447✔
685
        if (result.startsWith("\"") && result.endsWith("\"")) {
8,447!
UNCOV
686
            result = result.slice(1, -1);
×
UNCOV
687
        }
×
688

689
        // Strip port syntax: node:port or node:port:compass
690
        // Port syntax is only meaningful in edge statements, we just need the node ID
691
        const colonIndex = result.indexOf(":");
8,447✔
692
        if (colonIndex > 0) {
8,447✔
693
            result = result.substring(0, colonIndex);
34✔
694
        }
34✔
695

696
        return result;
8,447✔
697
    }
8,447✔
698

699
    private parseValue(value: string): string | number {
6✔
700
    // Remove quotes if present
701
        if (value.startsWith("\"") && value.endsWith("\"")) {
7,019!
UNCOV
702
            return value.slice(1, -1);
×
UNCOV
703
        }
×
704

705
        // Try to parse as number
706
        if (/^-?\d+$/.test(value)) {
7,019✔
707
            return parseInt(value, 10);
52✔
708
        }
52✔
709

710
        if (/^-?\d+\.\d+$/.test(value)) {
7,019✔
711
            return parseFloat(value);
9✔
712
        }
9✔
713

714
        return value;
6,958✔
715
    }
7,019✔
716
}
6✔
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