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

rokucommunity / roku-debug / #2019

pending completion
#2019

push

web-flow
Merge 15b99ed53 into 4961675eb

1897 of 2750 branches covered (68.98%)

Branch coverage included in aggregate %.

16 of 16 new or added lines in 3 files covered. (100.0%)

3447 of 4593 relevant lines covered (75.05%)

27.64 hits per line

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

91.44
/src/managers/BreakpointManager.ts
1
import * as fsExtra from 'fs-extra';
1✔
2
import { orderBy } from 'natural-orderby';
1✔
3
import type { CodeWithSourceMap } from 'source-map';
4
import { SourceNode } from 'source-map';
1✔
5
import type { DebugProtocol } from 'vscode-debugprotocol';
6
import { fileUtils, standardizePath } from '../FileUtils';
1✔
7
import type { ComponentLibraryProject, Project } from './ProjectManager';
8
import { standardizePath as s } from 'roku-deploy';
1✔
9
import type { SourceMapManager } from './SourceMapManager';
10
import type { LocationManager } from './LocationManager';
11
import { util } from '../util';
1✔
12
import { nextTick } from 'process';
13
import { EventEmitter } from 'eventemitter3';
1✔
14

15
export class BreakpointManager {
1✔
16

17
    public constructor(
18
        private sourceMapManager: SourceMapManager,
79✔
19
        private locationManager: LocationManager
79✔
20
    ) {
21

22
    }
23

24
    public launchConfiguration: {
25
        sourceDirs: string[];
26
        rootDir: string;
27
        enableSourceMaps?: boolean;
28
    };
29

30
    private emitter = new EventEmitter();
79✔
31

32
    private emit(eventName: 'breakpoints-verified', data: { breakpoints: AugmentedSourceBreakpoint[] });
33
    private emit(eventName: string, data: any) {
34
        this.emitter.emit(eventName, data);
13✔
35
    }
36

37
    /**
38
     * Subscribe to an event
39
     */
40
    public on(eventName: 'breakpoints-verified', handler: (data: { breakpoints: AugmentedSourceBreakpoint[] }) => any);
41
    public on(eventName: string, handler: (data: any) => any) {
42
        this.emitter.on(eventName, handler);
28✔
43
        return () => {
28✔
44
            this.emitter.off(eventName, handler);
×
45
        };
46
    }
47

48
    /**
49
     * A map of breakpoints by what file they were set in.
50
     * This does not handle any source-to-dest mapping...these breakpoints are stored in the file they were set in.
51
     * These breakpoints are all set before launch, and then this list is not changed again after that.
52
     * (this concept may need to be modified once we get live breakpoint support)
53
     */
54
    private breakpointsByFilePath = new Map<string, AugmentedSourceBreakpoint[]>();
79✔
55

56
    /**
57
     * A sequence used to generate unique client breakpoint IDs
58
     */
59
    private breakpointIdSequence = 1;
79✔
60

61
    /**
62
     * breakpoint lines are 1-based, and columns are zero-based
63
     */
64
    public setBreakpoint(srcPath: string, breakpoint: AugmentedSourceBreakpoint | DebugProtocol.SourceBreakpoint) {
65
        srcPath = this.sanitizeSourceFilePath(srcPath);
52✔
66

67
        //if a breakpoint gets set in rootDir, and we have sourceDirs, convert the rootDir path to sourceDirs path
68
        //so the breakpoint gets moved into the source file instead of the output file
69
        if (this.launchConfiguration?.sourceDirs && this.launchConfiguration.sourceDirs.length > 0) {
52!
70
            let lastWorkingPath = '';
×
71
            for (const sourceDir of this.launchConfiguration.sourceDirs) {
×
72
                srcPath = srcPath.replace(this.launchConfiguration.rootDir, sourceDir);
×
73
                if (fsExtra.pathExistsSync(srcPath)) {
×
74
                    lastWorkingPath = srcPath;
×
75
                }
76
            }
77
            srcPath = this.sanitizeSourceFilePath(lastWorkingPath);
×
78
        }
79

80
        //get the breakpoints array (and optionally initialize it if not set)
81
        let breakpointsArray = this.getBreakpointsForFile(srcPath, true);
52✔
82

83
        //only a single breakpoint can be defined per line. So, if we find one on this line, we'll augment that breakpoint rather than builiding a new one
84
        const existingBreakpoint = breakpointsArray.find(x => x.line === breakpoint.line);
52✔
85

86
        let bp = Object.assign(existingBreakpoint ?? {}, {
52✔
87
            //remove common attributes from any existing breakpoint so we don't end up with more info than we need
88
            ...{
89
                //default to 0 if the breakpoint is missing `column`
90
                column: 0,
91
                condition: undefined,
92
                hitCondition: undefined,
93
                logMessage: undefined
94
            },
95
            ...breakpoint,
96
            srcPath: srcPath,
97
            //assign a hash-like key to this breakpoint (so we can match against other similar breakpoints in the future)
98
            hash: this.getBreakpointKey(srcPath, breakpoint)
99
        }) as AugmentedSourceBreakpoint;
100

101
        //generate a new id for this breakpoint if one does not exist
102
        bp.id ??= this.breakpointIdSequence++;
52✔
103

104
        //all breakpoints default to false if not already set to true
105
        bp.verified ??= false;
52✔
106

107
        //if the breakpoint hash changed, mark the breakpoint as unverified
108
        if (existingBreakpoint?.hash !== bp.hash) {
52✔
109
            bp.verified = false;
43✔
110
        }
111

112
        //if this is a new breakpoint, add it to the list. (otherwise, the existing breakpoint is edited in-place)
113
        if (!existingBreakpoint) {
52✔
114
            breakpointsArray.push(bp);
43✔
115
        }
116

117
        //if this is one of the permanent breakpoints, mark it as verified immediately (only applicable to telnet sessions)
118
        if (this.getPermanentBreakpoint(bp.hash)) {
52✔
119
            this.setBreakpointDeviceId(bp.hash, bp.id);
2✔
120
            this.verifyBreakpoint(bp.id, true);
2✔
121
        }
122
        return bp;
52✔
123
    }
124

125
    /**
126
     * Find a breakpoint by its hash
127
     * @returns the breakpoint, or undefined if not found
128
     */
129
    private getBreakpointByHash(hash: string) {
130
        return this.getBreakpointsByHashes([hash])[0];
18✔
131
    }
132

133
    /**
134
     * Find a list of breakpoints by their hashes
135
     * @returns the breakpoint, or undefined if not found
136
     */
137
    private getBreakpointsByHashes(hashes: string[]) {
138
        const result = [] as AugmentedSourceBreakpoint[];
31✔
139
        for (const [, breakpoints] of this.breakpointsByFilePath) {
31✔
140
            for (const breakpoint of breakpoints) {
33✔
141
                if (hashes.includes(breakpoint.hash)) {
44✔
142
                    result.push(breakpoint);
33✔
143
                }
144
            }
145
        }
146
        return result;
31✔
147
    }
148

149
    /**
150
      * Find a breakpoint by its deviceId
151
      * @returns the breakpoint, or undefined if not found
152
      */
153
    private getBreakpointByDeviceId(deviceId: number) {
154
        return this.getBreakpointsByDeviceIds([deviceId])[0];
18✔
155
    }
156

157
    /**
158
     * Find a list of breakpoints by their deviceIds
159
     * @returns the breakpoints, or undefined if not found
160
     */
161
    private getBreakpointsByDeviceIds(deviceIds: number[]) {
162
        const result = [] as AugmentedSourceBreakpoint[];
18✔
163
        for (const [, breakpoints] of this.breakpointsByFilePath) {
18✔
164
            for (const breakpoint of breakpoints) {
19✔
165
                if (deviceIds.includes(breakpoint.deviceId)) {
25✔
166
                    result.push(breakpoint);
16✔
167
                }
168
            }
169
        }
170
        return result;
18✔
171
    }
172

173
    /**
174
     * Set the deviceId of a breakpoint
175
     */
176
    public setBreakpointDeviceId(hash: string, deviceId: number) {
177
        const breakpoint = this.getBreakpointByHash(hash);
18✔
178
        if (breakpoint) {
18✔
179
            breakpoint.deviceId = deviceId;
17✔
180
        }
181
    }
182

183
    /**
184
     * Mark this breakpoint as verified
185
     */
186
    public verifyBreakpoint(deviceId: number, isVerified = true) {
×
187
        const breakpoint = this.getBreakpointByDeviceId(deviceId);
18✔
188
        if (breakpoint) {
18✔
189
            breakpoint.verified = isVerified;
16✔
190
            this.queueVerifyEvent(breakpoint.hash);
16✔
191
            return true;
16✔
192
        } else {
193
            //couldn't find the breakpoint. return false so the caller can handle that properly
194
            return false;
2✔
195
        }
196
    }
197

198
    /**
199
     * Whenever breakpoints get verified, they need to be synced back to vscode.
200
     * This queues up a future function that will emit a batch of all verified breakpoints.
201
     * @param hash the breakpoint hash that identifies this specific breakpoint based on its features
202
     */
203
    private queueVerifyEvent(hash: string) {
204
        this.verifiedBreakpointKeys.push(hash);
16✔
205
        if (!this.isVerifyEventQueued) {
16✔
206
            this.isVerifyEventQueued = true;
13✔
207

208
            process.nextTick(() => {
13✔
209
                this.isVerifyEventQueued = false;
13✔
210
                const breakpoints = this.getBreakpointsByHashes(
13✔
211
                    this.verifiedBreakpointKeys.map(x => x)
16✔
212
                );
213
                this.verifiedBreakpointKeys = [];
13✔
214
                this.emit('breakpoints-verified', {
13✔
215
                    breakpoints: breakpoints
216
                });
217
            });
218
        }
219
    }
220
    private verifiedBreakpointKeys: string[] = [];
79✔
221
    private isVerifyEventQueued = false;
79✔
222

223
    /**
224
     * Generate a key based on the features of the breakpoint. Every breakpoint that exists at the same location
225
     * and has the same features should have the same key.
226
     */
227
    public getBreakpointKey(filePath: string, breakpoint: DebugProtocol.SourceBreakpoint | AugmentedSourceBreakpoint) {
228
        const key = `${standardizePath(filePath)}:${breakpoint.line}:${breakpoint.column ?? 0}`;
52✔
229

230
        const condition = breakpoint.condition?.trim();
52✔
231
        if (condition) {
52✔
232
            return `${key}-condition=${condition}`;
6✔
233
        }
234

235
        const hitCondition = parseInt(breakpoint.hitCondition?.trim());
46✔
236
        if (!isNaN(hitCondition)) {
46✔
237
            return `${key}-hitCondition=${hitCondition}`;
5✔
238
        }
239

240
        if (breakpoint.logMessage) {
41✔
241
            return `${key}-logMessage=${breakpoint.logMessage}`;
2✔
242
        }
243

244
        return `${key}-standard`;
39✔
245
    }
246

247
    /**
248
     * Set/replace/delete the list of breakpoints for this file.
249
     * @param srcPath
250
     * @param allBreakpointsForFile
251
     */
252
    public replaceBreakpoints(srcPath: string, allBreakpointsForFile: DebugProtocol.SourceBreakpoint[]): AugmentedSourceBreakpoint[] {
253
        srcPath = this.sanitizeSourceFilePath(srcPath);
33✔
254

255
        const currentBreakpoints = allBreakpointsForFile.map(breakpoint => this.setBreakpoint(srcPath, breakpoint));
38✔
256

257
        //delete all breakpoints from the file that are not currently in this list
258
        this.breakpointsByFilePath.set(
33✔
259
            srcPath,
260
            this.getBreakpointsForFile(srcPath).filter(x => currentBreakpoints.includes(x))
43✔
261
        );
262

263
        //get the final list of breakpoints
264
        return currentBreakpoints;
33✔
265
    }
266

267
    /**
268
     * Get a list of all breakpoint tasks that should be performed.
269
     * This will also exclude files with breakpoints that are not in scope.
270
     */
271
    private async getBreakpointWork(project: Project) {
272
        let result = {} as Record<string, Array<BreakpointWorkItem>>;
54✔
273

274
        //iterate over every file that contains breakpoints
275
        for (let [sourceFilePath, breakpoints] of this.breakpointsByFilePath) {
54✔
276
            for (let breakpoint of breakpoints) {
57✔
277
                //get the list of locations in staging that this breakpoint should be written to.
278
                //if none are found, then this breakpoint is ignored
279
                let stagingLocationsResult = await this.locationManager.getStagingLocations(
52✔
280
                    sourceFilePath,
281
                    breakpoint.line,
282
                    breakpoint.column,
283
                    [
284
                        ...project?.sourceDirs ?? [],
312!
285
                        project.rootDir
286
                    ],
287
                    project.stagingFolderPath,
288
                    project.fileMappings
289
                );
290

291
                for (let stagingLocation of stagingLocationsResult.locations) {
52✔
292
                    let relativeStagingPath = fileUtils.replaceCaseInsensitive(
30✔
293
                        stagingLocation.filePath,
294
                        fileUtils.standardizePath(
295
                            fileUtils.removeTrailingSlash(project.stagingFolderPath) + '/'
296
                        ),
297
                        ''
298
                    );
299
                    const pkgPath = 'pkg:/' + fileUtils
30✔
300
                        //replace staging folder path with nothing (so we can build a pkg path)
301
                        .replaceCaseInsensitive(
302
                            s`${stagingLocation.filePath}`,
303
                            s`${project.stagingFolderPath}`,
304
                            ''
305
                        )
306
                        //force to unix path separators
307
                        .replace(/[\/\\]+/g, '/')
308
                        //remove leading slash
309
                        .replace(/^\//, '');
310

311
                    let obj: BreakpointWorkItem = {
30✔
312
                        //add the breakpoint info
313
                        ...breakpoint,
314
                        //add additional info
315
                        srcPath: sourceFilePath,
316
                        rootDirFilePath: s`${project.rootDir}/${relativeStagingPath}`,
317
                        line: stagingLocation.lineNumber,
318
                        column: stagingLocation.columnIndex,
319
                        stagingFilePath: stagingLocation.filePath,
320
                        type: stagingLocationsResult.type,
321
                        pkgPath: pkgPath,
322
                        componentLibraryName: (project as ComponentLibraryProject).name
323
                    };
324
                    if (!result[stagingLocation.filePath]) {
30✔
325
                        result[stagingLocation.filePath] = [];
26✔
326
                    }
327
                    result[stagingLocation.filePath].push(obj);
30✔
328
                }
329
            }
330
        }
331
        //sort every breakpoint by location
332
        for (let stagingFilePath in result) {
54✔
333
            result[stagingFilePath] = this.sortAndRemoveDuplicateBreakpoints(result[stagingFilePath]);
26✔
334
        }
335

336
        return result;
54✔
337
    }
338

339
    public sortAndRemoveDuplicateBreakpoints<T extends { line: number; column?: number }>(
340
        breakpoints: Array<T>
341
    ) {
342
        breakpoints = orderBy(breakpoints, [x => x.line, x => x.column]);
174✔
343
        //throw out any duplicate breakpoints (walk backwards so this is easier)
344
        for (let i = breakpoints.length - 1; i >= 0; i--) {
52✔
345
            let breakpoint = breakpoints[i];
174✔
346
            let higherBreakpoint = breakpoints[i + 1];
174✔
347
            //only support one breakpoint per line
348
            if (higherBreakpoint && higherBreakpoint.line === breakpoint.line) {
174✔
349
                //throw out the higher breakpoint because it's probably the user-defined breakpoint
350
                breakpoints.splice(i + 1, 1);
3✔
351
            }
352
        }
353
        return breakpoints;
52✔
354
    }
355

356
    /**
357
     * Write "stop" lines into source code for each breakpoint of each file in the given project
358
     */
359
    public async writeBreakpointsForProject(project: Project) {
360
        let breakpointsByStagingFilePath = await this.getBreakpointWork(project);
11✔
361

362
        let promises = [] as Promise<any>[];
11✔
363
        for (let stagingFilePath in breakpointsByStagingFilePath) {
11✔
364
            const breakpoints = breakpointsByStagingFilePath[stagingFilePath];
11✔
365
            promises.push(this.writeBreakpointsToFile(stagingFilePath, breakpoints));
11✔
366
            for (const breakpoint of breakpoints) {
11✔
367
                //mark this breakpoint as verified
368
                this.setBreakpointDeviceId(breakpoint.hash, breakpoint.id);
14✔
369
                this.verifyBreakpoint(breakpoint.id, true);
14✔
370
                //add this breakpoint to the list of "permanent" breakpoints
371
                this.registerPermanentBreakpoint(breakpoint);
14✔
372
            }
373
        }
374

375
        await Promise.all(promises);
11✔
376

377
        //sort all permanent breakpoints by line and column
378
        for (const [key, breakpoints] of this.permanentBreakpointsBySrcPath) {
11✔
379
            this.permanentBreakpointsBySrcPath.set(key, orderBy(breakpoints, [x => x.line, x => x.column]));
14✔
380
        }
381
    }
382

383
    private registerPermanentBreakpoint(breakpoint: BreakpointWorkItem) {
384
        const collection = this.permanentBreakpointsBySrcPath.get(breakpoint.srcPath) ?? [];
14✔
385
        //clone the breakpoint so future updates don't mutate it.
386
        collection.push({ ...breakpoint });
14✔
387
        this.permanentBreakpointsBySrcPath.set(breakpoint.srcPath, collection);
14✔
388
    }
389

390
    /**
391
     * The list of breakpoints that were permanently written to a file at the start of a debug session. Used for line offset calculations.
392
     */
393
    private permanentBreakpointsBySrcPath = new Map<string, BreakpointWorkItem[]>();
79✔
394

395
    /**
396
     * Write breakpoints to the specified file, and update the sourcemaps to match
397
     */
398
    private async writeBreakpointsToFile(stagingFilePath: string, breakpoints: BreakpointWorkItem[]) {
399

400
        //do not crash if the file doesn't exist
401
        if (!await fsExtra.pathExists(stagingFilePath)) {
11✔
402
            util.log(`Path not found ${stagingFilePath}`);
1✔
403
            return;
1✔
404
        }
405

406
        //load the file as a string
407
        let fileContents = (await fsExtra.readFile(stagingFilePath)).toString();
10✔
408

409
        let originalFilePath = breakpoints[0].type === 'sourceMap'
10✔
410
            //the calling function will merge this sourcemap into the other existing sourcemap, so just use the same name because it doesn't matter
411
            ? breakpoints[0].rootDirFilePath
412
            //the calling function doesn't have a sourcemap for this file, so we need to point it to the sourceDirs found location (probably rootDir...)
413
            : breakpoints[0].srcPath;
414

415
        let sourceAndMap = this.getSourceAndMapWithBreakpoints(fileContents, originalFilePath, breakpoints);
10✔
416

417
        //if we got a map file back, write it to the filesystem
418
        if (sourceAndMap.map) {
10!
419
            let sourceMap = JSON.stringify(sourceAndMap.map);
10✔
420
            //It's ok to overwrite the file in staging because if the original code provided a source map,
421
            //then our LocationManager class will walk the sourcemap chain from staging, to rootDir, and then
422
            //on to the original location
423
            await fsExtra.writeFile(`${stagingFilePath}.map`, sourceMap);
10✔
424
            //update the in-memory version of this source map
425
            this.sourceMapManager.set(`${stagingFilePath}.map`, sourceMap);
10✔
426
        }
427

428
        //overwrite the file that now has breakpoints injected
429
        await fsExtra.writeFile(stagingFilePath, sourceAndMap.code);
10✔
430
    }
431

432
    private bpIndex = 1;
79✔
433
    public getSourceAndMapWithBreakpoints(fileContents: string, originalFilePath: string, breakpoints: BreakpointWorkItem[]) {
434
        let chunks = [] as Array<SourceNode | string>;
17✔
435

436
        //split the file by newline
437
        let lines = fileContents.split(/\r?\n/g);
17✔
438
        let newline = '\n';
17✔
439
        for (let originalLineIndex = 0; originalLineIndex < lines.length; originalLineIndex++) {
17✔
440
            let line = lines[originalLineIndex];
80✔
441
            //if is final line
442
            if (originalLineIndex === lines.length - 1) {
80✔
443
                newline = '';
17✔
444
            }
445
            //find breakpoints for this line (breakpoint lines are 1-indexed, but our lineIndex is 0-based)
446
            let lineBreakpoints = breakpoints.filter(bp => bp.line - 1 === originalLineIndex);
102✔
447
            //if we have a breakpoint, insert that before the line
448
            for (let bp of lineBreakpoints) {
80✔
449
                let linesForBreakpoint = this.getBreakpointLines(bp, originalFilePath);
20✔
450

451
                //separate each line for this breakpoint with a newline
452
                for (let bpLine of linesForBreakpoint) {
20✔
453
                    chunks.push(bpLine);
20✔
454
                    chunks.push('\n');
20✔
455
                }
456
            }
457

458
            //add the original code now
459
            chunks.push(
80✔
460
                //sourceNode expects 1-based row indexes
461
                new SourceNode(originalLineIndex + 1, 0, originalFilePath, `${line}${newline}`)
462
            );
463
        }
464

465
        let node = new SourceNode(null, null, originalFilePath, chunks);
17✔
466

467
        //if sourcemaps are disabled, skip sourcemap generation and only generate the code
468
        if (this.launchConfiguration?.enableSourceMaps === false) {
17!
469
            return {
×
470
                code: node.toString(),
471
                map: undefined
472
            } as CodeWithSourceMap;
473
        } else {
474
            return node.toStringWithSourceMap();
17✔
475
        }
476
    }
477

478
    private getBreakpointLines(breakpoint: BreakpointWorkItem, originalFilePath: string) {
479
        let lines = [] as Array<string | SourceNode>;
20✔
480
        if (breakpoint.logMessage) {
20✔
481
            let logMessage = breakpoint.logMessage;
3✔
482
            //wrap the log message in quotes
483
            logMessage = `"${logMessage}"`;
3✔
484
            let expressionsCheck = /\{(.*?)\}/g;
3✔
485
            let match: RegExpExecArray;
486

487
            // Get all the value to evaluate as expressions
488
            while ((match = expressionsCheck.exec(logMessage))) {
3✔
489
                logMessage = logMessage.replace(match[0], `"; ${match[1]};"`);
3✔
490
            }
491

492
            // add a PRINT statement right before this line with the formated log message
493
            lines.push(new SourceNode(breakpoint.line, 0, originalFilePath, `PRINT ${logMessage}`));
3✔
494
        } else if (breakpoint.condition) {
17✔
495
            // add a conditional STOP statement
496
            lines.push(new SourceNode(breakpoint.line, 0, originalFilePath, `if ${breakpoint.condition} then : STOP : end if`));
2✔
497
        } else if (breakpoint.hitCondition) {
15✔
498
            let hitCondition = parseInt(breakpoint.hitCondition);
3✔
499

500
            if (isNaN(hitCondition) || hitCondition === 0) {
3✔
501
                // add a STOP statement right before this line
502
                lines.push(`STOP`);
1✔
503
            } else {
504
                let prefix = `m.vscode_bp`;
2✔
505
                let bpName = `bp${this.bpIndex++}`;
2✔
506
                let checkHits = `if ${prefix}.${bpName} >= ${hitCondition} then STOP`;
2✔
507
                let increment = `${prefix}.${bpName} ++`;
2✔
508

509
                // Create the BrightScript code required to track the number of executions
510
                let trackingExpression = `
2✔
511
                    if Invalid = ${prefix} OR Invalid = ${prefix}.${bpName} then
512
                        if Invalid = ${prefix} then
513
                            ${prefix} = {${bpName}: 0}
514
                        else
515
                            ${prefix}.${bpName} = 0
516
                    else
517
                        ${increment} : ${checkHits}
518
                `;
519
                //coerce the expression into single-line
520
                trackingExpression = trackingExpression.replace(/\n/gi, '').replace(/\s+/g, ' ').trim();
2✔
521
                // Add the tracking expression right before this line
522
                lines.push(new SourceNode(breakpoint.line, 0, originalFilePath, trackingExpression));
2✔
523
            }
524
        } else {
525
            // add a STOP statement right before this line. Map the stop code to the line the breakpoint represents
526
            //because otherwise source-map will return null for this location
527
            lines.push(new SourceNode(breakpoint.line, 0, originalFilePath, 'STOP'));
12✔
528
        }
529
        return lines;
20✔
530
    }
531

532
    /**
533
     * Get the list of breakpoints for the specified file path, or an empty array
534
     */
535
    private getBreakpointsForFile(filePath: string, registerIfMissing = false): AugmentedSourceBreakpoint[] {
37✔
536
        let key = this.sanitizeSourceFilePath(filePath);
89✔
537
        const result = this.breakpointsByFilePath.get(key) ?? [];
89✔
538
        if (registerIfMissing === true) {
89✔
539
            this.breakpointsByFilePath.set(key, result);
52✔
540
        }
541
        return result;
89✔
542
    }
543

544
    /**
545
     * Get the permanent breakpoint with the specified hash
546
     * @returns the breakpoint with the matching hash, or undefined
547
     */
548
    public getPermanentBreakpoint(hash: string) {
549
        for (const [, breakpoints] of this.permanentBreakpointsBySrcPath) {
52✔
550
            for (const breakpoint of breakpoints) {
3✔
551
                if (breakpoint.hash === hash) {
3✔
552
                    return breakpoint;
2✔
553
                }
554
            }
555
        }
556
    }
557

558
    /**
559
     * Get the list of breakpoints that were written to the source file
560
     */
561
    public getPermanentBreakpointsForFile(srcPath: string) {
562
        return this.permanentBreakpointsBySrcPath.get(
26✔
563
            this.sanitizeSourceFilePath(srcPath)
564
        ) ?? [];
565
    }
566

567
    /**
568
     * File paths can be different casing sometimes,
569
     * so find the existing key if it exists, or return the file path if it doesn't exist
570
     */
571
    public sanitizeSourceFilePath(filePath: string) {
572
        filePath = fileUtils.standardizePath(filePath);
202✔
573

574
        for (let [key] of this.breakpointsByFilePath) {
202✔
575
            if (filePath.toLowerCase() === key.toLowerCase()) {
108✔
576
                return key;
92✔
577
            }
578
        }
579
        return filePath;
110✔
580
    }
581

582
    /**
583
     * Determine if there's a breakpoint set at the given staging folder and line.
584
     * This is not trivial, so only run when absolutely necessary
585
     * @param projects the list of projects to scan
586
     * @param pkgPath the path to the file in the staging directory
587
     * @param line the 0-based line for the breakpoint
588
     */
589
    public async lineHasBreakpoint(projects: Project[], pkgPath: string, line: number) {
590
        const workByProject = (await Promise.all(
×
591
            projects.map(project => this.getBreakpointWork(project))
×
592
        ));
593
        for (const projectWork of workByProject) {
×
594
            for (let key in projectWork) {
×
595
                const work = projectWork[key];
×
596
                for (const item of work) {
×
597
                    if (item.pkgPath === pkgPath && item.line - 1 === line) {
×
598
                        return true;
×
599
                    }
600
                }
601
            }
602
        }
603
    }
604

605

606
    /**
607
     * Get a diff of all breakpoints that have changed since the last time the diff was retrieved.
608
     * Sets the new baseline to the current state, so the next diff will be based on this new baseline.
609
     *
610
     * All projects should be passed in every time.
611
     */
612
    public async getDiff(projects: Project[]): Promise<Diff> {
613
        //if the diff is currently running, return an empty "nothing has changed" diff
614
        if (this.isGetDiffRunning) {
20!
615
            return {
×
616
                added: [],
617
                removed: [],
618
                unchanged: [...this.lastState.values()]
619
            };
620
        }
621
        try {
20✔
622
            this.isGetDiffRunning = true;
20✔
623

624
            const currentState = new Map<string, BreakpointWorkItem>();
20✔
625
            await Promise.all(
20✔
626
                projects.map(async (project) => {
627
                    //get breakpoint data for every project
628
                    const work = await this.getBreakpointWork(project);
43✔
629
                    for (const filePath in work) {
43✔
630
                        const fileWork = work[filePath];
15✔
631
                        for (const bp of fileWork) {
15✔
632
                            bp.stagingFilePath = fileUtils.postfixFilePath(bp.stagingFilePath, project.postfix, ['.brs']);
15✔
633
                            bp.pkgPath = fileUtils.postfixFilePath(bp.pkgPath, project.postfix, ['.brs']);
15✔
634
                            const key = [
15✔
635
                                bp.stagingFilePath,
636
                                bp.line,
637
                                bp.column,
638
                                bp.condition,
639
                                bp.hitCondition,
640
                                bp.logMessage
641
                            ].join('--');
642
                            //clone the breakpoint and then add it to the current state
643
                            currentState.set(key, { ...bp });
15✔
644
                        }
645
                    }
646
                })
647
            );
648

649
            const added = new Map<string, BreakpointWorkItem>();
20✔
650
            const removed = new Map<string, BreakpointWorkItem>();
20✔
651
            const unchanged = new Map<string, BreakpointWorkItem>();
20✔
652
            for (const key of [...currentState.keys(), ...this.lastState.keys()]) {
20✔
653
                const inCurrent = currentState.has(key);
22✔
654
                const inLast = this.lastState.has(key);
22✔
655
                //no change
656
                if (inLast && inCurrent) {
22✔
657
                    unchanged.set(key, currentState.get(key));
2✔
658

659
                    //added since last time
660
                } else if (!inLast && inCurrent) {
20✔
661
                    added.set(key, currentState.get(key));
14✔
662

663
                    //removed since last time
664
                } else {
665
                    removed.set(key, this.lastState.get(key));
6✔
666
                }
667
            }
668
            this.lastState = currentState;
20✔
669
            return {
20✔
670
                added: [...added.values()],
671
                removed: [...removed.values()],
672
                unchanged: [...unchanged.values()]
673
            };
674
        } finally {
675
            this.isGetDiffRunning = false;
20✔
676
        }
677
    }
678
    /**
679
     * Flag indicating whether a `getDiff` function is currently running
680
     */
681
    private isGetDiffRunning = false;
79✔
682
    private lastState = new Map<string, BreakpointWorkItem>();
79✔
683
}
684

685
export interface Diff {
686
    added: BreakpointWorkItem[];
687
    removed: BreakpointWorkItem[];
688
    unchanged: BreakpointWorkItem[];
689
}
690

691
export interface AugmentedSourceBreakpoint extends DebugProtocol.SourceBreakpoint {
692
    /**
693
     * The path to the source file where this breakpoint was originally set
694
     */
695
    srcPath: string;
696
    /**
697
     * A unique hash generated for the breakpoint at this exact file/line/column/feature. Every breakpoint with these same features should get the same hash
698
     */
699
    hash: string;
700
    /**
701
     * The device-provided breakpoint id. A missing ID means this breakpoint has not yet been verified by the device.
702
     */
703
    deviceId?: number;
704
    /**
705
     * A unique ID the debug adapter generates to help send updates to the client about this breakpoint
706
     */
707
    id: number;
708
    /**
709
     * This breakpoint has been verified (i.e. we were able to set it at the given location)
710
     */
711
    verified: boolean;
712
}
713

714
export interface BreakpointWorkItem {
715
    /**
716
     * The path to the source file where this breakpoint was originally set
717
     */
718
    srcPath: string;
719
    /**
720
     * The absolute path to the file in the staging folder
721
     */
722
    stagingFilePath: string;
723
    /**
724
     * The device path (i.e. `pkg:/source/main.brs`)
725
     */
726
    pkgPath: string;
727
    /**
728
     * The path to the rootDir for this breakpoint
729
     */
730
    rootDirFilePath: string;
731
    /**
732
     * The 1-based line number
733
     */
734
    line: number;
735
    /**
736
     * The device-provided breakpoint id. A missing ID means this breakpoint has not yet been verified by the device.
737
     */
738
    deviceId?: number;
739
    /**
740
     * An id generated by the debug adapter used to identify this breakpoint in the client
741
     */
742
    id: number;
743
    /**
744
     * A unique hash generated for the breakpoint at this exact file/line/column/feature. Every breakpoint with these same features should get the same hash
745
     */
746
    hash: string;
747
    /**
748
     * The 0-based column index
749
     */
750
    column: number;
751
    /**
752
     * If set, this breakpoint will only activate when this condition evaluates to true
753
     */
754
    condition?: string;
755
    /**
756
     * If set, this breakpoint will only activate once the breakpoint has been hit this many times.
757
     */
758
    hitCondition?: string;
759
    /**
760
     * If set, this breakpoint will emit a log message at runtime and will not actually stop at the breakpoint
761
     */
762
    logMessage?: string;
763
    /**
764
     * The name of the component library this belongs to. Will be null for the main project
765
     */
766
    componentLibraryName?: string;
767
    /**
768
     * `sourceMap` means derived from a source map.
769
     * `fileMap` means derived from the {src;dest} entry used by roku-deploy
770
     * `sourceDirs` means derived by walking up the `sourceDirs` list until a relative file was found
771
     */
772
    type: 'fileMap' | 'sourceDirs' | 'sourceMap';
773
}
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