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

rokucommunity / roku-debug / 26975146130

04 Jun 2026 07:40PM UTC coverage: 70.547% (+0.3%) from 70.261%
26975146130

push

github

web-flow
Validate breakpoint file types; add (disabled) AST line validation (#317)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>

3408 of 5118 branches covered (66.59%)

Branch coverage included in aggregate %.

93 of 96 new or added lines in 3 files covered. (96.88%)

5641 of 7709 relevant lines covered (73.17%)

44.94 hits per line

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

80.21
/src/managers/ProjectManager.ts
1
import * as fsExtra from 'fs-extra';
2✔
2
import * as path from 'path';
2✔
3
import { rokuDeploy, RokuDeploy, util as rokuDeployUtil } from 'roku-deploy';
2✔
4
import type { FileEntry } from 'roku-deploy';
5
import * as fastGlob from 'fast-glob';
2✔
6
import type { BreakpointManager } from './BreakpointManager';
7
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
8
import type { LocationManager, SourceLocation } from './LocationManager';
9
import { util } from '../util';
2✔
10
import { logger } from '../logging';
2✔
11
import { Cache } from 'brighterscript/dist/Cache';
2✔
12
import { BscProjectThreaded } from '../bsc/BscProjectThreaded';
2✔
13
import type { ScopeFunction } from '../bsc/BscProject';
14
import type { Position } from 'brighterscript';
15
import type { SourceMapPayload } from 'module';
16

17
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
18
const replaceInFile = require('replace-in-file');
2✔
19

20
export const componentLibraryPostfix = '__lib';
2✔
21

22
/**
23
 * Manages the collection of brightscript projects being used in a debug session.
24
 * Will contain the main project (in rootDir), as well as component libraries.
25
 */
26
export class ProjectManager {
2✔
27
    public constructor(
28
        options: {
29
            /**
30
             * A class that keeps track of all the breakpoints for a debug session.
31
             * It needs to be notified of any changes in breakpoints
32
             */
33
            breakpointManager: BreakpointManager;
34
            locationManager: LocationManager;
35
        }
36
    ) {
37
        this.breakpointManager = options.breakpointManager;
307✔
38
        this.locationManager = options.locationManager;
307✔
39
    }
40

41
    private breakpointManager: BreakpointManager;
42

43
    private locationManager: LocationManager;
44

45
    public launchConfiguration: {
46
        enableSourceMaps?: boolean;
47
        enableDebugProtocol?: boolean;
48
        packagePath: string;
49
    };
50

51
    public logger = logger.createLogger('[ProjectManager]');
307✔
52

53
    public mainProject: Project;
54
    public componentLibraryProjects = [] as ComponentLibraryProject[];
307✔
55

56
    public addComponentLibraryProject(project: ComponentLibraryProject) {
57
        this.componentLibraryProjects.push(project);
254✔
58
    }
59

60
    public getAllProjects() {
61
        return [
33✔
62
            ...(this.mainProject ? [this.mainProject] : []),
33✔
63
            ...(this.componentLibraryProjects ?? [])
99!
64
        ];
65
    }
66

67
    /**
68
     * Get the list of staging folder paths from all projects
69
     */
70
    public getStagingDirs() {
71
        let projects = [
15✔
72
            ...(this.mainProject ? [this.mainProject] : []),
15✔
73
            ...(this.componentLibraryProjects ?? [])
45!
74
        ];
75
        return projects.map(x => x.stagingDir);
15✔
76
    }
77

78
    /**
79
     * Get all of the functions avaiable for all scopes for this file.
80
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
81
     * @returns
82
     */
83
    public async getScopeFunctionsForFile(pkgPath: string): Promise<Array<ScopeFunction>> {
84
        let completions: ScopeFunction[] = [];
×
85
        try {
×
86
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
87
            completions = await fileInfo?.project.getScopeFunctionsForFile(fileInfo.relativePath);
×
88
        } catch (error) {
89
            this.logger.error(`error loading completions for file ${pkgPath}`, error);
×
90
        }
91
        return completions;
×
92
    }
93

94
    /**
95
     * Get the range of the scope for the given position in the file
96
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
97
     * @param position the position in the file to get the scope range for
98
     */
99
    public async getScopeRange(pkgPath: string, position: Position) {
100
        try {
×
101
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
102
            const parentFunctionRange = await fileInfo?.project.getScopeRange(fileInfo.relativePath, position);
×
103
            if (parentFunctionRange) {
×
104
                const [startPosition, endPosition] = await Promise.all([
×
105
                    this.getSourceLocation(pkgPath, parentFunctionRange.start.line + 1),
106
                    this.getSourceLocation(pkgPath, parentFunctionRange.end.line + 1)
107
                ]);
108
                return {
×
109
                    start: {
110
                        line: startPosition.lineNumber,
111
                        column: startPosition.columnIndex
112
                    },
113
                    end: {
114
                        line: endPosition.lineNumber,
115
                        column: endPosition.columnIndex
116
                    }
117
                };
118
            }
119
        } catch (error) {
120
            this.logger.error(`error loading scope range for file ${pkgPath}`, error);
×
121
        }
122
        return undefined;
×
123
    }
124

125
    /**
126
     * Given a debugger path and line number, compensate for the injected breakpoint line offsets
127
     * @param filePath - the path to the file that may or may not have breakpoints
128
     * @param debuggerLineNumber - the line number from the debugger
129
     */
130
    public getLineNumberOffsetByBreakpoints(filePath: string, debuggerLineNumber: number) {
131
        let breakpoints = this.breakpointManager.getPermanentBreakpointsForFile(filePath);
26✔
132
        //throw out duplicate breakpoints (account for entry breakpoint) and sort them ascending
133
        breakpoints = this.breakpointManager.sortAndRemoveDuplicateBreakpoints(breakpoints);
26✔
134

135
        let sourceLineByDebuggerLine = {};
26✔
136
        let sourceLineNumber = 0;
26✔
137
        for (let loopDebuggerLineNumber = 1; loopDebuggerLineNumber <= debuggerLineNumber; loopDebuggerLineNumber++) {
26✔
138
            sourceLineNumber++;
210✔
139
            sourceLineByDebuggerLine[loopDebuggerLineNumber] = sourceLineNumber;
210✔
140

141
            /**
142
             * A line with a breakpoint on it should share the same debugger line number.
143
             * The injected `STOP` line will be given the correct line number automatically,
144
             * but we need to compensate for the actual code line. So if there's a breakpoint
145
             * on this line, handle the next line's mapping as well (and skip one iteration of the loop)
146
             */
147
            // eslint-disable-next-line @typescript-eslint/no-loop-func
148
            let breakpointForLine = breakpoints.find(x => x.line === sourceLineNumber);
673✔
149
            if (breakpointForLine) {
210✔
150
                sourceLineByDebuggerLine[loopDebuggerLineNumber + 1] = sourceLineNumber;
79✔
151
                loopDebuggerLineNumber++;
79✔
152
            }
153
        }
154

155
        return sourceLineByDebuggerLine[debuggerLineNumber];
26✔
156
    }
157

158
    public sourceLocationCache = new Cache<string, Promise<SourceLocation>>();
307✔
159

160
    /**
161
     * @param debuggerPath
162
     * @param debuggerLineNumber - the 1-based line number from the debugger
163
     * @param debuggerColumnNumber - the 1-based column number from the debugger
164
     */
165
    public async getSourceLocation(debuggerPath: string, debuggerLineNumber: number, debuggerColumnNumber = 1) {
11✔
166
        return this.sourceLocationCache.getOrAdd(`${debuggerPath}-${debuggerLineNumber}`, async () => {
11✔
167
            //get source location using
168
            let stagingFileInfo = await this.getStagingFileInfo(debuggerPath);
11✔
169
            if (!stagingFileInfo) {
11!
170
                return;
×
171
            }
172
            let project = stagingFileInfo.project;
11✔
173

174
            //remove the component library postfix if present
175
            if (project instanceof ComponentLibraryProject) {
11✔
176
                stagingFileInfo.absolutePath = fileUtils.unPostfixFilePath(stagingFileInfo.absolutePath, project.postfix);
1✔
177
                stagingFileInfo.relativePath = fileUtils.unPostfixFilePath(stagingFileInfo.relativePath, project.postfix);
1✔
178
            }
179

180
            let sourceLocation = await this.locationManager.getSourceLocation({
11✔
181
                lineNumber: debuggerLineNumber,
182
                columnIndex: debuggerColumnNumber - 1,
183
                fileMappings: project.fileMappings,
184
                rootDir: project.rootDir,
185
                stagingFilePath: stagingFileInfo.absolutePath,
186
                stagingDir: project.stagingDir,
187
                sourceDirs: project.sourceDirs,
188
                enableSourceMaps: this.launchConfiguration?.enableSourceMaps ?? true
66!
189
            });
190

191
            //if sourcemaps are disabled, and this is a telnet debug dession, account for breakpoint offsets
192
            if (sourceLocation && this.launchConfiguration?.enableSourceMaps === false && !this.launchConfiguration.enableDebugProtocol) {
11!
193
                sourceLocation.lineNumber = this.getLineNumberOffsetByBreakpoints(sourceLocation.filePath, sourceLocation.lineNumber);
×
194
            }
195

196
            if (!sourceLocation?.filePath) {
11✔
197
                //couldn't find a source location. At least send back the staging file information so the user can still debug
198
                return {
5✔
199
                    filePath: stagingFileInfo.absolutePath,
200
                    lineNumber: sourceLocation?.lineNumber || debuggerLineNumber,
25!
201
                    columnIndex: debuggerColumnNumber - 1
202
                } as SourceLocation;
203
            } else {
204
                return sourceLocation;
6✔
205
            }
206
        });
207
    }
208

209
    /**
210
     *
211
     * @param stagingDir - the path to
212
     */
213
    public async registerEntryBreakpoint(stagingDir: string) {
214
        //find the main function from the staging flder
215
        let entryPoint = await fileUtils.findEntryPoint(stagingDir);
×
216

217
        //convert entry point staging location to source location
218
        let sourceLocation = await this.getSourceLocation(entryPoint.relativePath, entryPoint.lineNumber);
×
219

220
        this.logger.info(`Registering entry breakpoint at ${sourceLocation.filePath}:${sourceLocation.lineNumber} (${entryPoint.pathAbsolute}:${entryPoint.lineNumber})`);
×
221
        //register the entry breakpoint
222
        this.breakpointManager.setBreakpoint(sourceLocation.filePath, {
×
223
            //+1 to select the first line of the function
224
            line: sourceLocation.lineNumber + 1
225
        });
226
    }
227

228
    /**
229
     * Given a debugger-relative file path, find the path to that file in the staging directory.
230
     * This supports the standard out dir, as well as component library out dirs
231
     * @param debuggerPath the path to the file which was provided by the debugger
232
     * @param stagingDir - the path to the root of the staging folder (where all of the files were copied before deployment)
233
     * @return a full path to the file in the staging directory
234
     */
235
    public async getStagingFileInfo(debuggerPath: string) {
236
        let project: Project;
237

238
        let componentLibraryIndex = fileUtils.getComponentLibraryIndexFromFileName(debuggerPath, componentLibraryPostfix);
15✔
239
        //component libraries
240
        if (componentLibraryIndex !== undefined) {
15✔
241
            let lib = this.componentLibraryProjects.find(x => x.libraryIndex === componentLibraryIndex);
3✔
242
            if (lib) {
3!
243
                project = lib;
3✔
244
            } else {
245
                throw new Error(`There is no component library with index ${componentLibraryIndex}`);
×
246
            }
247
            //standard project files
248
        } else {
249
            project = this.mainProject;
12✔
250
        }
251

252
        let relativePath: string;
253

254
        //if the path starts with a scheme (i.e. pkg:/ or complib:/, we have an exact match.
255
        if (util.getFileScheme(debuggerPath)) {
15✔
256
            relativePath = util.removeFileScheme(debuggerPath);
10✔
257
        } else {
258
            relativePath = await fileUtils.findPartialFileInDirectory(debuggerPath, project.stagingDir);
5✔
259
        }
260
        if (relativePath) {
15!
261
            relativePath = fileUtils.removeLeadingSlash(
15✔
262
                fileUtils.standardizePath(relativePath
263
                )
264
            );
265
            return {
15✔
266
                relativePath: relativePath,
267
                absolutePath: s`${project.stagingDir}/${relativePath}`,
268
                project: project
269
            };
270
        } else {
271
            return undefined;
×
272
        }
273
    }
274

275
    public dispose() {
276
        util.applyDispose(this.getAllProjects());
15✔
277
    }
278
}
279

280
export interface AddProjectParams {
281
    rootDir: string;
282
    outDir: string;
283
    packagePath?: string;
284
    sourceDirs?: string[];
285
    files: Array<FileEntry>;
286
    injectRaleTrackerTask?: boolean;
287
    raleTrackerTaskFileLocation?: string;
288
    injectRdbOnDeviceComponent?: boolean;
289
    rdbFilesBasePath?: string;
290
    bsConst?: Record<string, boolean>;
291
    stagingDir?: string;
292
    enhanceREPLCompletions: boolean;
293
}
294

295
export class Project {
2✔
296
    constructor(params: AddProjectParams) {
297
        if (!params?.rootDir) {
602!
298
            throw new Error('rootDir is required');
×
299
        }
300
        this.rootDir = fileUtils.standardizePath(params.rootDir);
602✔
301

302
        if (!params?.outDir) {
602!
303
            throw new Error('outDir is required');
×
304
        }
305
        this.outDir = fileUtils.standardizePath(params.outDir);
602✔
306
        this.stagingDir = params.stagingDir ?? rokuDeploy.getOptions(this).stagingDir;
602✔
307
        this.bsConst = params.bsConst;
602✔
308
        this.sourceDirs = (params.sourceDirs ?? [])
602✔
309
            //standardize every sourcedir
310
            .map(x => fileUtils.standardizePath(x));
98✔
311
        this.injectRaleTrackerTask = params.injectRaleTrackerTask ?? false;
602✔
312
        this.raleTrackerTaskFileLocation = params.raleTrackerTaskFileLocation;
602✔
313
        this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false;
602✔
314
        this.rdbFilesBasePath = params.rdbFilesBasePath;
602✔
315
        this.files = params.files ?? [];
602✔
316
        this.packagePath = params.packagePath;
602✔
317
        this.enhanceREPLCompletions = params.enhanceREPLCompletions;
602✔
318
    }
319
    public rootDir: string;
320
    public outDir: string;
321
    public packagePath: string;
322
    public sourceDirs: string[];
323
    public files: Array<FileEntry>;
324
    public stagingDir: string;
325
    public fileMappings: Array<{ src: string; dest: string }>;
326

327
    /**
328
     * Absolute staging paths of every file referenced by a `<script uri="...">` tag in any staged XML
329
     * component. Roku loads these as BrightScript regardless of file extension, so they are valid
330
     * breakpoint targets even when they don't end in `.brs`. Populated during `stage()` (the single
331
     * staging-file walk in `preprocessStagingFiles`) so consumers don't have to re-scan the staging dir.
332
     */
333
    public scriptReferencedFiles = new Set<string>();
602✔
334
    public bsConst: Record<string, boolean>;
335
    public injectRaleTrackerTask: boolean;
336
    public raleTrackerTaskFileLocation: string;
337
    public injectRdbOnDeviceComponent: boolean;
338
    public rdbFilesBasePath: string;
339
    public enhanceREPLCompletions: boolean;
340

341
    /**
342
     * A BrighterScript project for the stagingDir
343
     */
344
    private stagingBscProject = new BscProjectThreaded();
602✔
345

346
    //the default project doesn't have a postfix, but component libraries will have a postfix, so just use empty string to standardize the postfix logic
347
    public get postfix() {
348
        return '';
56✔
349
    }
350

351
    private logger = logger.createLogger(`[${ProjectManager.name}]`);
602✔
352

353
    public async stage() {
354
        if (!this.fileMappings) {
18✔
355
            this.fileMappings = await this.getFileMappings();
10✔
356
        }
357

358
        //copy all project files to the staging folder
359
        await rokuDeploy.prepublishToStaging({
18✔
360
            rootDir: this.rootDir,
361
            stagingDir: this.stagingDir,
362
            files: this.fileMappings,
363
            outDir: this.outDir,
364
            //we already fetched the file mappings ourselves, so roku-deploy doesn't need to glob the files again
365
            resolveFilesArray: false
366
        });
367

368
        await this.preprocessStagingFiles();
18✔
369

370
        if (this.enhanceREPLCompletions) {
18✔
371
            //activate our background brighterscript ProgramBuilder now that the staging directory contains the final production project
372
            this.stagingBscProject.activate({
1✔
373
                rootDir: this.stagingDir,
374
                files: ['**/*'],
375
                watch: false,
376
                createPackage: false,
377
                deploy: false,
378
                copyToStaging: false,
379
                showDiagnosticsInConsole: false,
380
                logLevel: 'error',
381
                //this project is only used for file and scope lookups, so skip all validations since that takes a while and we don't care
382
                validate: false
383
            }).catch((e) => {
384
                this.logger.error('Error activating staging project.', e);
×
385
            });
386
        }
387

388
        //preload the original location of every file
389
        await this.resolveFileMappingsForSourceDirs();
18✔
390

391
        await this.transformManifestWithBsConst();
18✔
392

393
        await this.copyAndTransformRaleTrackerTask();
18✔
394

395
        await this.copyAndTransformRDB();
18✔
396
    }
397

398
    /**
399
     * Get all of the functions available for all scopes for this file.
400
     * @param relativePath path to the file relative to rootDir
401
     * @returns
402
     */
403
    public getScopeFunctionsForFile(relativePath: string) {
404
        if (this.enhanceREPLCompletions && this.stagingBscProject?.isActivated) {
×
405
            return this.stagingBscProject.getScopeFunctionsForFile({ relativePath: relativePath });
×
406
        } else {
407
            return [];
×
408
        }
409
    }
410

411
    /**
412
     * Get the range of the scope for the given position in the file
413
     * @param relativePath path to the file relative to rootDir
414
     * @param position the position in the file to get the scope range for
415
     */
416
    public async getScopeRange(relativePath: string, position: Position) {
417
        if (this.stagingBscProject?.isActivated) {
×
418
            return this.stagingBscProject.getScopeRange({ relativePath: relativePath, position: position });
×
419
        } else {
420
            return undefined;
×
421
        }
422
    }
423

424
    /**
425
     * If the project uses sourceDirs, replace every `fileMapping.src` with its original location in sourceDirs
426
     */
427
    private resolveFileMappingsForSourceDirs() {
428
        return Promise.all([
18✔
429
            this.fileMappings.map(async x => {
430
                let stagingFilePathRelative = fileUtils.getRelativePath(this.stagingDir, x.dest);
38✔
431
                let sourceDirFilePath = await fileUtils.findFirstRelativeFile(stagingFilePathRelative, this.sourceDirs);
38✔
432
                if (sourceDirFilePath) {
38!
433
                    x.src = sourceDirFilePath;
×
434
                }
435
            })
436
        ]);
437
    }
438

439
    /**
440
     * Walk every staged file once and apply all necessary rewrites for files that were moved
441
     * from a different source location:
442
     *  - .map files: rewrite `sources` paths to be relative to the new staging location
443
     *  - .brs/.xml files: rewrite the sourceMappingURL comment path to point to the staged map
444
     */
445
    private async preprocessStagingFiles() {
446
        const srcToDestMap = new Map<string, string>();
142✔
447
        const destToSrcMap = new Map<string, string>();
142✔
448
        for (const mapping of this.fileMappings) {
142✔
449
            srcToDestMap.set(mapping.src, mapping.dest);
182✔
450
            destToSrcMap.set(mapping.dest, mapping.src);
182✔
451
        }
452

453
        //reset before re-scanning
454
        this.scriptReferencedFiles.clear();
142✔
455

456
        //walk over every file
457
        const stagedFiles: string[] = (await fastGlob('**/*', { cwd: this.stagingDir, absolute: true, onlyFiles: true }))
142✔
458
            .map((f: string) => fileUtils.standardizePath(f));
2,606✔
459

460
        await Promise.all(stagedFiles.map(async (stagingFilePath: string) => {
142✔
461
            const ext = path.extname(stagingFilePath).toLowerCase();
2,606✔
462
            const originalSrcPath = destToSrcMap.get(stagingFilePath);
2,606✔
463

464
            //.map files are handled separately (they get their own JSON read), and never need the
465
            //text-content path below. Skip maps that aren't in fileMappings (generated after staging).
466
            if (ext === '.map') {
2,606✔
467
                if (originalSrcPath) {
46✔
468
                    await this.fixSourceMapSources({
45✔
469
                        stagingMapPath: stagingFilePath,
470
                        originalMapPath: originalSrcPath
471
                    });
472
                }
473
                return;
46✔
474
            }
475

476
            //read each text file at most once and share the contents between the two consumers below:
477
            // - collectScriptReferencedFiles: runs for ALL staged xml (a component may be generated during
478
            //   staging, so it isn't necessarily in fileMappings)
479
            // - fixSourceMapComment: runs only for files that were moved from a source dir (in fileMappings)
480
            //binary files need neither, so we never read them.
481
            const isXml = ext === '.xml';
2,560✔
482
            const needsCommentFix = !!originalSrcPath;
2,560✔
483
            if (Project.binaryExtensions.has(ext) || (!isXml && !needsCommentFix)) {
2,560✔
484
                return;
2,486✔
485
            }
486

487
            let contents: string;
488
            try {
74✔
489
                contents = await fsExtra.readFile(stagingFilePath, 'utf8');
74✔
490
            } catch (e) {
NEW
491
                this.logger.debug('Error reading staged file during preprocess', { stagingFilePath, error: e });
×
NEW
492
                return;
×
493
            }
494

495
            if (isXml) {
74✔
496
                this.collectScriptReferencedFiles(stagingFilePath, contents);
14✔
497
            }
498
            if (needsCommentFix) {
74✔
499
                await this.fixSourceMapComment(stagingFilePath, originalSrcPath, srcToDestMap, contents);
67✔
500
            }
501
        }));
502
    }
503

504
    /**
505
     * Parse a staged XML file's contents for `<script uri="...">` tags and add each referenced file's
506
     * absolute staging path to {@link scriptReferencedFiles}. `pkg:/`/`libpkg:/` uris resolve from the
507
     * staging root; bare relative uris resolve from the XML file's own directory. Roku loads these as
508
     * BrightScript regardless of extension, so they are valid breakpoint targets.
509
     * @param contents the already-loaded file contents (read once by the staging walk)
510
     */
511
    private collectScriptReferencedFiles(xmlStagingPath: string, contents: string) {
512
        const scriptUriRegex = /<script\b[^>]*\buri\s*=\s*"([^"]*)"[^>]*\/?>/gi;
14✔
513
        let match: RegExpExecArray;
514
        while ((match = scriptUriRegex.exec(contents)) !== null) {
14✔
515
            const uri = match[1];
8✔
516
            const protocolIndex = uri.indexOf(':/');
8✔
517
            let absolutePath: string;
518
            if (protocolIndex >= 0) {
8✔
519
                //pkg:/ or libpkg:/ — resolve from staging root
520
                const relativePath = uri.substring(protocolIndex + 2).replace(/^\//, '');
7✔
521
                absolutePath = s`${this.stagingDir}/${relativePath}`;
7✔
522
            } else {
523
                //relative path — resolve from the XML file's directory
524
                absolutePath = s`${path.resolve(path.dirname(xmlStagingPath), uri)}`;
1✔
525
            }
526
            this.scriptReferencedFiles.add(absolutePath);
8✔
527
        }
528
    }
529

530
    /**
531
     * Per-path write locks. Async file ops interleave (e.g. `preprocessStagingFiles` fans out
532
     * tasks that can target the same staging `.map`), so we queue all writes to a given path
533
     * through a single promise chain. Entries clean themselves up once their chain is idle.
534
     */
535
    private writeLocks = new Map<string, Promise<unknown>>();
602✔
536

537
    private serializeWrite<T>(filePath: string, work: () => Promise<T>): Promise<T> {
538
        const key = fileUtils.standardizePath(filePath).toLowerCase();
105✔
539
        const prev = this.writeLocks.get(key) ?? Promise.resolve();
105✔
540
        //run work whether the prior op resolved or rejected — failures upstream shouldn't poison the chain
541
        const next = prev.then(work, work);
105✔
542
        this.writeLocks.set(key, next);
105✔
543
        //drop the entry once nothing else has chained onto it
544
        void next.catch(() => { /* swallow — caller's await sees the real error */ }).then(() => {
105✔
545
            if (this.writeLocks.get(key) === next) {
105✔
546
                this.writeLocks.delete(key);
104✔
547
            }
548
        });
549
        return next;
105✔
550
    }
551

552
    /**
553
     * Serialized wrapper around `fsExtra.writeFile`. Use in place of `fsExtra.writeFile` anywhere
554
     * a path might also be written by another concurrent task in this Project.
555
     */
556
    private writeFile(filePath: string, data: Parameters<typeof fsExtra.writeFile>[1], options?: Parameters<typeof fsExtra.writeFile>[2]) {
557
        return this.serializeWrite(filePath, () => fsExtra.writeFile(filePath, data, options));
64✔
558
    }
559

560
    /**
561
     * Serialized wrapper around `fsExtra.copyFile`. The dest path is the one we serialize on,
562
     * since that's what gets written.
563
     */
564
    private copyFile(srcPath: string, destPath: string) {
565
        return this.serializeWrite(destPath, () => fsExtra.copyFile(srcPath, destPath));
41✔
566
    }
567

568
    /**
569
     * Rewrite the `sources` paths in a staged .map file so they are relative to the map's
570
     * new staging location rather than the original source directory.
571
     */
572
    private async fixSourceMapSources(params: { stagingMapPath: string; originalMapPath: string }) {
573
        const { stagingMapPath, originalMapPath } = params;
86✔
574

575
        try {
86✔
576
            const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload;
86✔
577
            if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) {
85✔
578
                return;
56✔
579
            }
580
            // Resolve sources relative to original map's base dir (honoring sourceRoot if present)
581
            const originalBaseDir = path.resolve(
29✔
582
                //sourceRoot should resolve relative to originalMapDir, or keep as-is when absolute path
583
                path.dirname(originalMapPath),
584
                sourceMap.sourceRoot ?? ''
87✔
585
            );
586

587
            const stagingMapDir = path.dirname(stagingMapPath);
29✔
588

589
            sourceMap.sources = sourceMap.sources.map((source) => {
29✔
590
                const absoluteSourcePath = path.resolve(originalBaseDir, source);
30✔
591
                return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath));
30✔
592
            });
593

594
            // Clear sourceRoot since sources are now relative to the map file's new location
595
            delete sourceMap.sourceRoot;
29✔
596

597
            await this.writeFile(stagingMapPath, JSON.stringify(sourceMap));
29✔
598
        } catch (e) {
599
            this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e);
1✔
600
        }
601
    }
602

603

604
    public static readonly binaryExtensions = new Set([
2✔
605
        // images
606
        '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg',
607
        '.heic', '.heif', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng',
608
        // video
609
        '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg',
610
        '.m2v', '.ts', '.mts', '.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
611
        // audio
612
        '.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
613
        // fonts
614
        '.ttf', '.otf', '.woff', '.woff2', '.eot',
615
        // archives / binary containers
616
        '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar', '.pkg', '.exe', '.dll', '.so',
617
        // documents / other binary formats
618
        '.pdf', '.psd', '.ai', '.eps', '.indd',
619
        // roku-specific
620
        '.roku', '.rdb', '.squashfs'
621
    ]);
622

623
    /**
624
     * Extracts the sourceMappingURL comment from the given file contents.
625
     *
626
     * `match[3]` is the path (which may be relative or absolute)
627
     * @param contents
628
     * @returns
629
     */
630
    public static getSourceMapComment(contents: string) {
631

632
        //https://regex101.com/r/FMRJNy/2
633
        const commentMatch = [
112✔
634
            ...contents.matchAll(/^([ \t]*(?:'|<!--)?[ \t]*)((?:\/\/)?[ \t]*[#@][ \t]*sourceMappingURL=(.+\b))(?:|-->)?/gm)
635
        ].pop();
636
        if (commentMatch) {
112✔
637
            return {
59✔
638
                /**
639
                 * The entire matched comment, including any leading whitespace and comment characters (e.g. `'` or `<!--`), which should be preserved when rewriting the comment
640
                 */
641
                fullMatch: commentMatch?.[0],
177!
642
                /**
643
                 * The leading whitespace and comment characters (e.g. `'` or `<!--`) before the actual `sourceMappingURL` text, which should be preserved when rewriting the comment
644
                 */
645
                leadingInfo: commentMatch?.[1],
177!
646
                /**
647
                 * The entire comment text without the leadingInfo (e.g. `//# sourceMappingURL=someFile.map`)
648
                 */
649
                wholeComment: commentMatch?.[2],
177!
650
                /**
651
                 * The path to the source map file (e.g. `someFile.map`)
652
                 */
653
                mapPath: commentMatch?.[3]
177!
654
            };
655
        } else {
656
            return undefined;
53✔
657
        }
658
    }
659

660
    /**
661
     * Rewrite the sourceMappingURL comment in a staged .brs or .xml file so the path points
662
     * to the map file's new staging location.
663
     *
664
     * Recognised comment forms (# and legacy @ are both accepted; // is optional for brs/xml):
665
     *   BRS:   ' [//] [#|@] sourceMappingURL=<path>
666
     *   XML:   <!-- [//] [#|@] sourceMappingURL=<path> -->
667
     *   other: // \s* [#|@] sourceMappingURL=<path>
668
     *
669
     * When rewriting, the canonical modern form is always written:
670
     *   BRS:   '//# sourceMappingURL=<path>
671
     *   XML:   <!--//# sourceMappingURL=<path> -->
672
     *   other: //# sourceMappingURL=<path>
673
     */
674
    private async fixSourceMapComment(stagingFilePath: string, originalSrcPath: string, srcToDestMap: Map<string, string>, contents: string) {
675
        try {
67✔
676
            const commentMatch = Project.getSourceMapComment(contents);
67✔
677

678
            let absoluteMapPath: string;
679

680
            if (commentMatch) {
67✔
681
                absoluteMapPath = fileUtils.standardizePath(
34✔
682
                    path.isAbsolute(commentMatch.mapPath)
34✔
683
                        ? commentMatch.mapPath
684
                        : path.resolve(path.dirname(originalSrcPath), commentMatch.mapPath)
685
                );
686

687
                //copy the sourcemap right next to our file in staging
688
                absoluteMapPath = await this.colocateSourceMap({
34✔
689
                    absoluteMapPath: absoluteMapPath,
690
                    stagingFilePath: stagingFilePath
691
                });
692

693
            } else {
694
                // No comment — check if a colocated map exists next to the original source file
695
                absoluteMapPath = fileUtils.standardizePath(originalSrcPath + '.map');
33✔
696

697
                //there is no colocated map next to the original source file
698
                if (!await fsExtra.pathExists(absoluteMapPath)) {
33✔
699
                    return;
26✔
700
                }
701

702
                //copy the sourcemap right next to our file in staging — the debugger will find it automatically
703
                await this.colocateSourceMap({
7✔
704
                    absoluteMapPath: absoluteMapPath,
705
                    stagingFilePath: stagingFilePath
706
                });
707
                return;
7✔
708
            }
709

710
            // If the map was also staged, point at its new location; otherwise point back at the original
711
            const mapTarget = srcToDestMap.get(absoluteMapPath) ?? absoluteMapPath;
34!
712
            const newRelativePath = fileUtils.standardizePath(
34✔
713
                path.relative(path.dirname(stagingFilePath), mapTarget)
714
            );
715

716
            const newComment = `${commentMatch.leadingInfo.trimEnd()}//# sourceMappingURL=${newRelativePath}`;
34✔
717
            contents = contents.replace(commentMatch.fullMatch, newComment);
34✔
718
            await this.writeFile(stagingFilePath, contents, 'utf8');
34✔
719
        } catch (e) {
720
            this.logger.error(`Error updating sourceMappingURL comment in '${stagingFilePath}'`, e);
×
721
        }
722
    }
723

724
    private async colocateSourceMap(options: { stagingFilePath: string; absoluteMapPath: string }) {
725
        //copy the sourcemap right next to our file (skip if it's already there)
726
        const stagingMapPath = `${options.stagingFilePath}.map`;
41✔
727
        if (fileUtils.standardizePath(options.absoluteMapPath) !== fileUtils.standardizePath(stagingMapPath)) {
41!
728
            await this.copyFile(options.absoluteMapPath, stagingMapPath);
41✔
729
        }
730
        await this.fixSourceMapSources({
41✔
731
            stagingMapPath: stagingMapPath,
732
            originalMapPath: options.absoluteMapPath
733
        });
734
        return stagingMapPath;
41✔
735
    }
736

737

738
    /**
739
     * Apply the bsConst transformations to the manifest file for this project
740
     */
741
    public async transformManifestWithBsConst() {
742
        if (this.bsConst) {
18✔
743
            let manifestPath = s`${this.stagingDir}/manifest`;
1✔
744
            if (await fsExtra.pathExists(manifestPath)) {
1!
745
                // Update the bs_const values in the manifest in the staging folder before side loading the channel
746
                let fileContents = (await fsExtra.readFile(manifestPath)).toString();
1✔
747
                fileContents = this.updateManifestBsConsts(this.bsConst, fileContents);
1✔
748
                await this.writeFile(manifestPath, fileContents);
1✔
749
            }
750
        }
751
    }
752

753
    public updateManifestBsConsts(consts: Record<string, boolean>, fileContents: string): string {
754
        let bsConstLine: string;
755
        let missingConsts: string[] = [];
5✔
756
        let lines = fileContents.split(/\r?\n/g);
5✔
757

758
        let newLine: string;
759
        //loop through the lines until we find the bs_const line if it exists
760
        for (const line of lines) {
5✔
761
            if (line.toLowerCase().startsWith('bs_const')) {
53✔
762
                bsConstLine = line;
5✔
763
                newLine = line;
5✔
764
                break;
5✔
765
            }
766
        }
767

768
        if (bsConstLine) {
5!
769
            // update the consts in the manifest and check for missing consts
770
            missingConsts = Object.keys(consts).reduce((results, key) => {
5✔
771
                let match = new RegExp('(' + key + '\\s*=\\s*[true|false]+[^\\S\\r\\n]*\)', 'i').exec(bsConstLine);
7✔
772
                if (match) {
7!
773
                    newLine = newLine.replace(match[1], `${key}=${consts[key].toString()}`);
7✔
774
                } else {
775
                    results.push(key);
×
776
                }
777

778
                return results;
7✔
779
            }, []);
780

781
            // check for consts that where not in the manifest
782
            if (missingConsts.length > 0) {
5!
783
                throw new Error(`The following bs_const keys were not defined in the channel's manifest:\n\n${missingConsts.join(',\n')}`);
×
784
            } else {
785
                // update the manifest contents
786
                return fileContents.replace(bsConstLine, newLine);
5✔
787
            }
788
        } else {
789
            throw new Error('bs_const was defined in the launch.json but not in the channel\'s manifest');
×
790
        }
791
    }
792

793
    public static RALE_TRACKER_TASK_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rale_tracker_task = createObject("roSGNode", "TrackerTask") ' Roku Advanced Layout Editor Support`;
2✔
794
    public static RALE_TRACKER_ENTRY = 'vscode_rale_tracker_entry';
2✔
795
    /**
796
     * Search the project files for the comment "' vscode_rale_tracker_entry" and replace it with the code needed to start the TrackerTask.
797
     */
798
    public async copyAndTransformRaleTrackerTask() {
799
        // inject the tracker task into the staging files if we have everything we need
800
        if (!this.injectRaleTrackerTask || !this.raleTrackerTaskFileLocation) {
31✔
801
            return;
18✔
802
        }
803
        try {
13✔
804
            await fsExtra.copy(this.raleTrackerTaskFileLocation, s`${this.stagingDir}/components/TrackerTask.xml`);
13✔
805
            this.logger.log('Tracker task successfully injected');
13✔
806
            // Search for the tracker task entry injection point
807
            const trackerReplacementResult = await replaceInFile({
13✔
808
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
809
                from: new RegExp(`^.*'\\s*${Project.RALE_TRACKER_ENTRY}.*$`, 'mig'),
810
                to: (match: string) => {
811
                    // Strip off the comment
812
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
813
                    if (/[\S]/.exec(startOfLine)) {
12✔
814
                        // There was some form of code before the tracker entry
815
                        // append and use single line syntax
816
                        startOfLine += ': ';
6✔
817
                    }
818
                    return `${startOfLine}${Project.RALE_TRACKER_TASK_CODE}`;
12✔
819
                }
820
            });
821
            const injectedFiles = trackerReplacementResult
13✔
822
                .filter(result => result.hasChanged)
26✔
823
                .map(result => result.file);
12✔
824

825
            if (injectedFiles.length === 0) {
13✔
826
                console.error(`WARNING: Unable to find an entry point for Tracker Task.\nPlease make sure that you have the following comment in your BrightScript project: "\' ${Project.RALE_TRACKER_ENTRY}"`);
1✔
827
            }
828
        } catch (err) {
829
            console.error(err);
×
830
        }
831
    }
832

833
    public static RDB_ODC_NODE_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rdb_odc_node = createObject("roSGNode", "RTA_OnDeviceComponent") ' RDB OnDeviceComponent`;
2✔
834
    public static RDB_ODC_ENTRY = 'vscode_rdb_on_device_component_entry';
2✔
835
    /**
836
     * Search the project files for the RTA_ODC_ENTRY comment and replace it with the code needed to start RTA_OnDeviceComponent which is used by RDB.
837
     */
838
    public async copyAndTransformRDB() {
839
        // inject the on device component into the staging files if we have everything we need
840
        if (!this.injectRdbOnDeviceComponent || !this.rdbFilesBasePath) {
34✔
841
            return;
19✔
842
        }
843
        try {
15✔
844

845
            let files: string[] = await fastGlob(
15✔
846
                //fast-glob requires forward slashes, so convert any backslashes in the provided path to forward slashes before globbing
847
                `${this.rdbFilesBasePath}/**/*`.replace(/[\\/]+/g, '/'),
848
                {
849
                    cwd: './',
850
                    absolute: false,
851
                    followSymbolicLinks: true
852
                }
853
            );
854
            for (let filePathAbsolute of files) {
15✔
855
                const promises = [];
28✔
856
                //only include files (i.e. skip directories)
857
                if (await util.isFile(filePathAbsolute)) {
28!
858
                    const relativePath = s`${filePathAbsolute}`.replace(s`${this.rdbFilesBasePath}`, '');
28✔
859
                    const destinationPath = s`${this.stagingDir}/${relativePath}`;
28✔
860
                    promises.push(fsExtra.copy(filePathAbsolute, destinationPath));
28✔
861
                }
862
                await Promise.all(promises);
28✔
863
                this.logger.log('RDB OnDeviceComponent successfully injected');
28✔
864
            }
865

866
            // Search for the tracker task entry injection point
867
            const replacementResult = await replaceInFile({
15✔
868
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
869
                from: new RegExp(`^.*'\\s*${Project.RDB_ODC_ENTRY}.*$`, 'mig'),
870
                to: (match: string) => {
871
                    // Strip off the comment
872
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
873
                    if (/[\S]/.exec(startOfLine)) {
12✔
874
                        // There was some form of code before the tracker entry
875
                        // append and use single line syntax
876
                        startOfLine += ': ';
6✔
877
                    }
878
                    return `${startOfLine}${Project.RDB_ODC_NODE_CODE}`;
12✔
879
                }
880
            });
881
            const injectedFiles = replacementResult
14✔
882
                .filter(result => result.hasChanged)
42✔
883
                .map(result => result.file);
12✔
884

885
            if (injectedFiles.length === 0) {
14✔
886
                console.error(`WARNING: Unable to find an entry point for RDB.\nPlease make sure that you have the following comment in your BrightScript project: "\' ${Project.RDB_ODC_ENTRY}"`);
2✔
887
            }
888
        } catch (err) {
889
            console.error(err);
1✔
890
        }
891
    }
892

893
    /**
894
     *
895
     * @param stagingPath
896
     */
897
    public async zipPackage(params: { retainStagingFolder: boolean }) {
898
        const options = rokuDeploy.getOptions({
3✔
899
            ...this,
900
            ...params
901
        });
902

903
        let packagePath = this.packagePath;
3✔
904
        if (!this.packagePath) {
3✔
905
            //make sure the output folder exists
906
            await fsExtra.ensureDir(options.outDir);
2✔
907

908
            packagePath = rokuDeploy.getOutputZipFilePath(options);
2✔
909
        }
910

911
        //ensure the manifest file exists in the staging folder
912
        if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) {
3!
913
            throw new Error(`Cannot zip package: missing manifest file in "${options.stagingDir}"`);
×
914
        }
915

916
        // create a zip of the staging folder
917
        await rokuDeploy.zipFolder(options.stagingDir, packagePath, undefined, [
3✔
918
            '**/*',
919
            //exclude sourcemap files (they're large and can't be parsed on-device anyway...)
920
            '!**/*.map'
921
        ]);
922

923
        //delete the staging folder unless told to retain it.
924
        if (options.retainStagingDir !== true) {
3!
925
            await fsExtra.remove(options.stagingDir);
×
926
        }
927
    }
928

929
    /**
930
     * Get the file paths from roku-deploy, and ensure the dest paths are absolute
931
     * (`dest` paths are relative in later versions of roku-deploy)
932
     */
933
    protected async getFileMappings() {
934
        let fileMappings = await rokuDeploy.getFilePaths(this.files, this.rootDir, true, this.stagingDir);
19✔
935
        return fileMappings;
19✔
936
    }
937

938
    public dispose() {
939
        this.stagingBscProject?.dispose?.();
×
940
    }
941
}
942

943
export interface ComponentLibraryConstructorParams extends AddProjectParams {
944
    outFile: string;
945
    libraryIndex: number;
946
    install?: boolean;
947
}
948

949
export class ComponentLibraryProject extends Project {
2✔
950
    constructor(params: ComponentLibraryConstructorParams) {
951
        super(params);
286✔
952
        this.outFile = params.outFile;
286✔
953
        this.libraryIndex = params.libraryIndex;
286✔
954
        this.install = params.install ?? false;
286✔
955
    }
956
    public outFile: string;
957
    public libraryIndex: number;
958
    public install: boolean;
959
    /**
960
     * The name of the component library that this project represents. This is loaded during `this.computeOutFileName`
961
     */
962
    public name: string;
963

964
    /**
965
     * Takes a component Library and checks the outFile for replaceable values pulled from the libraries manifest
966
     * @param manifestPath the path to the manifest file to check
967
     */
968
    private async computeOutFileName(manifestPath: string) {
969
        let regexp = /\$\{([\w\d_]*)\}/;
10✔
970
        let renamingMatch: RegExpExecArray;
971
        let manifestValues = await util.convertManifestToObject(manifestPath);
10✔
972
        if (!manifestValues) {
10✔
973
            throw new Error(`Cannot find manifest file at "${manifestPath}"\n\nCould not complete automatic component library naming.`);
1✔
974
        }
975

976
        //load the component libary name from the manifest
977
        this.name = manifestValues.sg_component_libs_provided || manifestValues.bs_libs_provided;
9✔
978

979
        // search the outFile for replaceable values such as ${title}
980
        while ((renamingMatch = regexp.exec(this.outFile))) {
9✔
981

982
            // replace the replaceable key with the manifest value
983
            let manifestVariableName = renamingMatch[1];
5✔
984
            let manifestVariableValue = manifestValues[manifestVariableName];
5✔
985
            if (manifestVariableValue) {
5!
986
                this.outFile = this.outFile.replace(renamingMatch[0], manifestVariableValue);
5✔
987
            } else {
988
                throw new Error(`Cannot find manifest value:\n"${manifestVariableName}"\n\nCould not complete automatic component library naming.`);
×
989
            }
990
        }
991
    }
992

993
    public async stage() {
994
        /*
995
         Compute the file mappings now (i.e. don't let the parent class compute them).
996
         This must be done BEFORE finding the manifest file location.
997
         */
998
        this.fileMappings = await this.getFileMappings();
9✔
999

1000
        let expectedManifestDestPath = fileUtils.standardizePath(`${this.stagingDir}/manifest`).toLowerCase();
9✔
1001
        //find the file entry with the `dest` value of `${stagingDir}/manifest` (case insensitive)
1002
        let manifestFileEntry = this.fileMappings.find(x => x.dest.toLowerCase() === expectedManifestDestPath);
9✔
1003
        if (manifestFileEntry) {
9!
1004
            //read the manifest from `src` since nothing has been copied to staging yet
1005
            await this.computeOutFileName(manifestFileEntry.src);
9✔
1006
        } else {
1007
            throw new Error(`Could not find manifest path for component library at '${this.rootDir}'`);
×
1008
        }
1009
        let fileNameWithoutExtension = path.basename(this.outFile, path.extname(this.outFile));
9✔
1010

1011
        let defaultStagingDir = this.stagingDir;
9✔
1012

1013
        //compute the staging folder path.
1014
        this.stagingDir = s`${this.outDir}/${fileNameWithoutExtension}`;
9✔
1015

1016
        /*
1017
          The fileMappings were created using the default stagingDir (because we need the manifest path
1018
          to compute the out file name and staging path), so we need to replace the default stagingDir
1019
          with the actual stagingDir.
1020
         */
1021
        for (let fileMapping of this.fileMappings) {
9✔
1022
            fileMapping.dest = fileUtils.replaceCaseInsensitive(fileMapping.dest, defaultStagingDir, this.stagingDir);
10✔
1023
        }
1024

1025
        return super.stage();
9✔
1026
    }
1027

1028
    /**
1029
     * The text used as a postfix for every brs file so we can accurately track the location of the files
1030
     * back to their original component library whenever the debugger truncates the file path.
1031
     */
1032
    public get postfix() {
1033
        return `${componentLibraryPostfix}${this.libraryIndex}`;
12✔
1034
    }
1035

1036
    public async postfixFiles() {
1037
        let pathDetails = {};
2✔
1038
        await Promise.all(this.fileMappings.map(async (fileMapping) => {
2✔
1039
            let relativePath = fileUtils.removeLeadingSlash(
×
1040
                fileUtils.getRelativePath(this.stagingDir, fileMapping.dest)
1041
            );
1042
            let postfixedPath = fileUtils.postfixFilePath(relativePath, this.postfix, ['.brs']);
×
1043
            if (postfixedPath !== relativePath) {
×
1044
                // Rename the brs files to include the postfix namespacing tag
1045
                await fsExtra.move(fileMapping.dest, path.join(this.stagingDir, postfixedPath));
×
1046
                // Add to the map of original paths and the new paths
1047
                pathDetails[postfixedPath] = relativePath;
×
1048
            }
1049
        }));
1050

1051
        // Update all the file name references in the library to the new file names
1052
        await replaceInFile({
2✔
1053
            files: [
1054
                path.join(this.stagingDir, '**/*.xml'),
1055
                path.join(this.stagingDir, '**/*.brs')
1056
            ],
1057
            from: /uri\s*=\s*"(.+)\.brs"/gi,
1058
            to: (match: string) => {
1059
                // only alter file ending if it is a) pkg:/ url or b) relative url
1060
                let isPkgUrl = !!/^uri\s*=\s*"(pkg:|libpkg:)\//i.exec(match);
8✔
1061
                let isRelativeUrl = !/:\//i.exec(match);
8✔
1062
                if (isPkgUrl || isRelativeUrl) {
8✔
1063
                    return match.replace('.brs', this.postfix + '.brs');
6✔
1064
                } else {
1065
                    return match;
2✔
1066
                }
1067
            }
1068
        });
1069
    }
1070
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc