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

rokucommunity / roku-debug / 31208140211

07 Aug 2026 06:41PM UTC coverage: 73.212% (+0.3%) from 72.934%
31208140211

Pull #398

github

web-flow
Merge 69b8e80ac into 553a0b2b5
Pull Request #398: Migrate to roku-deploy v4 and Roku Cloud Emulator support- #399

3901 of 5576 branches covered (69.96%)

Branch coverage included in aggregate %.

173 of 195 new or added lines in 12 files covered. (88.72%)

3 existing lines in 2 files now uncovered.

6069 of 8042 relevant lines covered (75.47%)

49.25 hits per line

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

82.43
/src/managers/ProjectManager.ts
1
import * as fsExtra from 'fs-extra';
2✔
2
import * as path from 'path';
2✔
3
import { 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
 * Staging info for a single project, used when describing all projects to a client.
24
 */
25
export interface ProjectStagingInfo {
26
    /**
27
     * The kind of project. `main` is the application project (always exactly one); `componentLibrary`
28
     * is a component library project.
29
     */
30
    type: 'main' | 'componentLibrary';
31
    /**
32
     * Absolute path to the project's staging directory.
33
     */
34
    stagingDir: string;
35
}
36

37
/**
38
 * Manages the collection of brightscript projects being used in a debug session.
39
 * Will contain the main project (in rootDir), as well as component libraries.
40
 */
41
export class ProjectManager {
2✔
42
    public constructor(
43
        options: {
44
            /**
45
             * A class that keeps track of all the breakpoints for a debug session.
46
             * It needs to be notified of any changes in breakpoints
47
             */
48
            breakpointManager: BreakpointManager;
49
            locationManager: LocationManager;
50
        }
51
    ) {
52
        this.breakpointManager = options.breakpointManager;
433✔
53
        this.locationManager = options.locationManager;
433✔
54
    }
55

56
    private breakpointManager: BreakpointManager;
57

58
    private locationManager: LocationManager;
59

60
    public launchConfiguration: {
61
        enableSourceMaps?: boolean;
62
        enableDebugProtocol?: boolean;
63
        packagePath: string;
64
    };
65

66
    public logger = logger.createLogger('[ProjectManager]');
433✔
67

68
    public mainProject: Project;
69
    public componentLibraryProjects = [] as ComponentLibraryProject[];
433✔
70

71
    public addComponentLibraryProject(project: ComponentLibraryProject) {
72
        this.componentLibraryProjects.push(project);
262✔
73
    }
74

75
    public getAllProjects() {
76
        return [
88✔
77
            ...(this.mainProject ? [this.mainProject] : []),
88✔
78
            ...(this.componentLibraryProjects ?? [])
264!
79
        ];
80
    }
81

82
    /**
83
     * Get the list of staging folder paths from all projects
84
     */
85
    public getStagingDirs() {
86
        let projects = [
17✔
87
            ...(this.mainProject ? [this.mainProject] : []),
17✔
88
            ...(this.componentLibraryProjects ?? [])
51!
89
        ];
90
        return projects.map(x => x.stagingDir);
17✔
91
    }
92

93
    /**
94
     * Rewrite `Library "file.brs"` statements across every project so a reference to a file exported by a
95
     * component library points at that library's postfixed file name. Only rewritten when the consumer requires
96
     * that library AND the library exports a file with that name (from its `libsource`).
97
     *
98
     * `bs_libs_required` is satisfied only by `bs_libs_provided`, and `sg_component_libs_required` only by
99
     * `sg_component_libs_provided` - the two mechanisms never cross (though one library may declare both).
100
     *
101
     * Must run AFTER every component library has been staged and postfixed.
102
     */
103
    public async applyLibraryReferencePostfixes() {
104
        for (const consumer of this.getAllProjects()) {
40✔
105
            //skip the file walk unless at least one library we require is actually provided by a loaded library
106
            //(matched per-mechanism, same as the rewrite below)
107
            const hasProvidedLibrary = this.componentLibraryProjects.some(library =>
92✔
108
                consumer.bsLibsRequired?.some(name => library.bsLibsProvided?.includes(name)) ||
128!
109
                consumer.sgComponentLibsRequired?.some(name => library.sgComponentLibsProvided?.includes(name))
14!
110
            );
111
            if (!hasProvidedLibrary) {
92✔
112
                continue;
64✔
113
            }
114

115
            await replaceInFile({
28✔
116
                files: [
117
                    path.join(consumer.stagingDir, '**/*.brs')
118
                ],
119
                //don't throw when a project has no brs files
120
                allowEmptyPaths: true,
121
                from: /(Library\s+")([^"]+)(")/gi,
122
                to: (match: string, prefix: string, fileName: string, suffix: string) => {
123
                    //which of the libraries we require via `bs_libs_required` exports this file? `bs_libs_required`
124
                    //is satisfied ONLY by `bs_libs_provided`
125
                    const bsLibrary = this.componentLibraryProjects.find(library =>
39✔
126
                        consumer.bsLibsRequired?.some(name => library.bsLibsProvided?.includes(name)) &&
55!
127
                        library.getExportedLibraryFileNames().includes(fileName)
128
                    );
129

130
                    //same question for `sg_component_libs_required`, satisfied ONLY by `sg_component_libs_provided`
131
                    const sgComponentLibrary = this.componentLibraryProjects.find(library =>
39✔
132
                        consumer.sgComponentLibsRequired?.some(name => library.sgComponentLibsProvided?.includes(name)) &&
75!
133
                        library.getExportedLibraryFileNames().includes(fileName)
134
                    );
135

136
                    //two DIFFERENT libraries export this file, one per mechanism, so we can't know which one the
137
                    //device would load. Warn, then make the educated guess: prefer the `bs_libs_provided` library.
138
                    //(one library declaring both manifest keys is fine: it resolves to itself either way)
139
                    if (bsLibrary && sgComponentLibrary && bsLibrary !== sgComponentLibrary) {
39✔
140
                        this.logger.warn(
3✔
141
                            `Ambiguous 'Library "${fileName}"' in '${consumer.stagingDir}': provided by both`,
142
                            `bs_libs_provided '${bsLibrary.name}' and sg_component_libs_provided`,
143
                            `'${sgComponentLibrary.name}'. Using '${bsLibrary.name}'.`
144
                        );
145
                    }
146

147
                    //leave the statement untouched if it doesn't reference a file from a required library
148
                    const library = bsLibrary ?? sgComponentLibrary;
39✔
149
                    if (!library) {
39✔
150
                        return match;
5✔
151
                    }
152
                    return `${prefix}${fileName.replace(/\.brs$/i, `${library.postfix}.brs`)}${suffix}`;
34✔
153
                }
154
            });
155
        }
156
    }
157

158
    /**
159
     * Get staging-dir info for every project. The main project is always first; component libraries
160
     * follow in order. This main-first ordering is a contract that clients rely on, so it is covered
161
     * by unit tests to guard against regressions.
162
     */
163
    public getProjectStagingInfo(): ProjectStagingInfo[] {
164
        return this.getAllProjects().map((project) => ({
4✔
165
            type: project instanceof ComponentLibraryProject ? 'componentLibrary' : 'main',
4✔
166
            stagingDir: project.stagingDir
167
        }));
168
    }
169

170
    /**
171
     * Get all of the functions avaiable for all scopes for this file.
172
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
173
     * @returns
174
     */
175
    public async getScopeFunctionsForFile(pkgPath: string): Promise<Array<ScopeFunction>> {
176
        let completions: ScopeFunction[] = [];
×
177
        try {
×
178
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
179
            completions = await fileInfo?.project.getScopeFunctionsForFile(fileInfo.relativePath);
×
180
        } catch (error) {
181
            this.logger.error(`error loading completions for file ${pkgPath}`, error);
×
182
        }
183
        return completions;
×
184
    }
185

186
    /**
187
     * Get the range of the scope for the given position in the file
188
     * @param pkgPath the device path of the file (probably with `pkg:` or `libpkg` or something...)
189
     * @param position the position in the file to get the scope range for
190
     */
191
    public async getScopeRange(pkgPath: string, position: Position) {
192
        try {
×
193
            const fileInfo = await this.getStagingFileInfo(pkgPath);
×
194
            const parentFunctionRange = await fileInfo?.project.getScopeRange(fileInfo.relativePath, position);
×
195
            if (parentFunctionRange) {
×
196
                const [startPosition, endPosition] = await Promise.all([
×
197
                    this.getSourceLocation(pkgPath, parentFunctionRange.start.line + 1),
198
                    this.getSourceLocation(pkgPath, parentFunctionRange.end.line + 1)
199
                ]);
200
                return {
×
201
                    start: {
202
                        line: startPosition.lineNumber,
203
                        column: startPosition.columnIndex
204
                    },
205
                    end: {
206
                        line: endPosition.lineNumber,
207
                        column: endPosition.columnIndex
208
                    }
209
                };
210
            }
211
        } catch (error) {
212
            this.logger.error(`error loading scope range for file ${pkgPath}`, error);
×
213
        }
214
        return undefined;
×
215
    }
216

217
    /**
218
     * Given a debugger path and line number, compensate for the injected breakpoint line offsets
219
     * @param filePath - the path to the file that may or may not have breakpoints
220
     * @param debuggerLineNumber - the line number from the debugger
221
     */
222
    public getLineNumberOffsetByBreakpoints(filePath: string, debuggerLineNumber: number) {
223
        let breakpoints = this.breakpointManager.getPermanentBreakpointsForFile(filePath);
26✔
224
        //throw out duplicate breakpoints (account for entry breakpoint) and sort them ascending
225
        breakpoints = this.breakpointManager.sortAndRemoveDuplicateBreakpoints(breakpoints);
26✔
226

227
        let sourceLineByDebuggerLine = {};
26✔
228
        let sourceLineNumber = 0;
26✔
229
        for (let loopDebuggerLineNumber = 1; loopDebuggerLineNumber <= debuggerLineNumber; loopDebuggerLineNumber++) {
26✔
230
            sourceLineNumber++;
210✔
231
            sourceLineByDebuggerLine[loopDebuggerLineNumber] = sourceLineNumber;
210✔
232

233
            /**
234
             * A line with a breakpoint on it should share the same debugger line number.
235
             * The injected `STOP` line will be given the correct line number automatically,
236
             * but we need to compensate for the actual code line. So if there's a breakpoint
237
             * on this line, handle the next line's mapping as well (and skip one iteration of the loop)
238
             */
239
            // eslint-disable-next-line @typescript-eslint/no-loop-func
240
            let breakpointForLine = breakpoints.find(x => x.line === sourceLineNumber);
673✔
241
            if (breakpointForLine) {
210✔
242
                sourceLineByDebuggerLine[loopDebuggerLineNumber + 1] = sourceLineNumber;
79✔
243
                loopDebuggerLineNumber++;
79✔
244
            }
245
        }
246

247
        return sourceLineByDebuggerLine[debuggerLineNumber];
26✔
248
    }
249

250
    public sourceLocationCache = new Cache<string, Promise<SourceLocation>>();
433✔
251

252
    /**
253
     * @param debuggerPath
254
     * @param debuggerLineNumber - the 1-based line number from the debugger
255
     * @param debuggerColumnNumber - the 1-based column number from the debugger
256
     */
257
    public async getSourceLocation(debuggerPath: string, debuggerLineNumber: number, debuggerColumnNumber = 1) {
11✔
258
        return this.sourceLocationCache.getOrAdd(`${debuggerPath}-${debuggerLineNumber}`, async () => {
11✔
259
            //get source location using
260
            let stagingFileInfo = await this.getStagingFileInfo(debuggerPath);
11✔
261
            if (!stagingFileInfo) {
11!
262
                return;
×
263
            }
264
            let project = stagingFileInfo.project;
11✔
265

266
            //remove the component library postfix if present
267
            if (project instanceof ComponentLibraryProject) {
11✔
268
                stagingFileInfo.absolutePath = fileUtils.unPostfixFilePath(stagingFileInfo.absolutePath, project.postfix);
1✔
269
                stagingFileInfo.relativePath = fileUtils.unPostfixFilePath(stagingFileInfo.relativePath, project.postfix);
1✔
270
            }
271

272
            let sourceLocation = await this.locationManager.getSourceLocation({
11✔
273
                lineNumber: debuggerLineNumber,
274
                columnIndex: debuggerColumnNumber - 1,
275
                fileMappings: project.fileMappings,
276
                rootDir: project.rootDir,
277
                stagingFilePath: stagingFileInfo.absolutePath,
278
                stagingDir: project.stagingDir,
279
                sourceDirs: project.sourceDirs,
280
                enableSourceMaps: this.launchConfiguration?.enableSourceMaps ?? true
66!
281
            });
282

283
            //if sourcemaps are disabled, and this is a telnet debug dession, account for breakpoint offsets
284
            if (sourceLocation && this.launchConfiguration?.enableSourceMaps === false && !this.launchConfiguration.enableDebugProtocol) {
11!
285
                sourceLocation.lineNumber = this.getLineNumberOffsetByBreakpoints(sourceLocation.filePath, sourceLocation.lineNumber);
×
286
            }
287

288
            if (!sourceLocation?.filePath) {
11✔
289
                //couldn't find a source location. At least send back the staging file information so the user can still debug
290
                return {
5✔
291
                    filePath: stagingFileInfo.absolutePath,
292
                    lineNumber: sourceLocation?.lineNumber || debuggerLineNumber,
25!
293
                    columnIndex: debuggerColumnNumber - 1
294
                } as SourceLocation;
295
            } else {
296
                return sourceLocation;
6✔
297
            }
298
        });
299
    }
300

301
    /**
302
     *
303
     * @param stagingDir - the path to
304
     */
305
    public async registerEntryBreakpoint(stagingDir: string) {
306
        //find the main function from the staging flder
307
        let entryPoint = await fileUtils.findEntryPoint(stagingDir);
×
308

309
        //convert entry point staging location to source location
310
        let sourceLocation = await this.getSourceLocation(entryPoint.relativePath, entryPoint.lineNumber);
×
311

312
        this.logger.info(`Registering entry breakpoint at ${sourceLocation.filePath}:${sourceLocation.lineNumber} (${entryPoint.pathAbsolute}:${entryPoint.lineNumber})`);
×
313
        //register the entry breakpoint
314
        this.breakpointManager.setBreakpoint(sourceLocation.filePath, {
×
315
            //+1 to select the first line of the function
316
            line: sourceLocation.lineNumber + 1
317
        });
318
    }
319

320
    /**
321
     * Given a debugger-relative file path, find the path to that file in the staging directory.
322
     * This supports the standard out dir, as well as component library out dirs
323
     * @param debuggerPath the path to the file which was provided by the debugger
324
     * @param stagingDir - the path to the root of the staging folder (where all of the files were copied before deployment)
325
     * @return a full path to the file in the staging directory
326
     */
327
    public async getStagingFileInfo(debuggerPath: string) {
328
        let componentLibraryIndex = fileUtils.getComponentLibraryIndexFromFileName(debuggerPath, componentLibraryPostfix);
23✔
329

330
        //the path carries a `__lib<index>` postfix, so we know exactly which component library it belongs to
331
        if (componentLibraryIndex !== undefined) {
23✔
332
            let lib = this.componentLibraryProjects.find(x => x.libraryIndex === componentLibraryIndex);
8✔
333
            if (!lib) {
6!
334
                throw new Error(`There is no component library with index ${componentLibraryIndex}`);
×
335
            }
336
            return this.buildStagingFileInfo(debuggerPath, lib);
6✔
337
        }
338

339
        //No `__lib<index>` postfix on the path. It's either a main-project file, or a file from a
340
        //component library that has postfixing disabled (those report device paths that look identical
341
        //to the main project's). Try the main project first.
342
        let stagingFileInfo = await this.buildStagingFileInfo(debuggerPath, this.mainProject);
17✔
343

344
        //if the file actually exists in the main project, use it
345
        if (stagingFileInfo && await fsExtra.pathExists(stagingFileInfo.absolutePath)) {
17✔
346
            return stagingFileInfo;
8✔
347
        }
348

349
        //otherwise, fall back to any component library that has postfixing disabled, since those are the
350
        //only other source of un-postfixed device paths. Use the first one that has the file on disk.
351
        for (const lib of this.componentLibraryProjects) {
9✔
352
            if (lib.enablePostfix === false) {
9✔
353
                const libStagingFileInfo = await this.buildStagingFileInfo(debuggerPath, lib);
3✔
354
                if (libStagingFileInfo && await fsExtra.pathExists(libStagingFileInfo.absolutePath)) {
3!
355
                    return libStagingFileInfo;
3✔
356
                }
357
            }
358
        }
359

360
        //nothing matched on disk; preserve prior behavior by returning the main-project result (if any)
361
        return stagingFileInfo;
6✔
362
    }
363

364
    /**
365
     * Resolve a debugger-reported path to a staging file within a specific project. Returns undefined if the
366
     * path could not be mapped into that project's staging directory.
367
     */
368
    private async buildStagingFileInfo(debuggerPath: string, project: Project) {
369
        let relativePath: string;
370

371
        //if the path starts with a scheme (i.e. pkg:/ or complib:/), we have an exact match.
372
        if (util.getFileScheme(debuggerPath)) {
26✔
373
            relativePath = util.removeFileScheme(debuggerPath);
21✔
374
        } else {
375
            relativePath = await fileUtils.findPartialFileInDirectory(debuggerPath, project.stagingDir);
5✔
376
        }
377
        if (relativePath) {
26!
378
            relativePath = fileUtils.removeLeadingSlash(
26✔
379
                fileUtils.standardizePath(relativePath)
380
            );
381
            return {
26✔
382
                relativePath: relativePath,
383
                absolutePath: s`${project.stagingDir}/${relativePath}`,
384
                project: project
385
            };
386
        } else {
387
            return undefined;
×
388
        }
389
    }
390

391
    public dispose() {
392
        util.applyDispose(this.getAllProjects());
17✔
393
    }
394
}
395

396
export interface AddProjectParams {
397
    rootDir: string;
398
    outDir: string;
399
    packagePath?: string;
400
    sourceDirs?: string[];
401
    files: Array<FileEntry>;
402
    injectRaleTrackerTask?: boolean;
403
    raleTrackerTaskFileLocation?: string;
404
    injectRdbOnDeviceComponent?: boolean;
405
    rdbFilesBasePath?: string;
406
    bsConst?: Record<string, boolean>;
407
    stagingDir?: string;
408
    enhanceREPLCompletions: boolean;
409
}
410

411
export class Project {
2✔
412
    constructor(params: AddProjectParams) {
413
        if (!params?.rootDir) {
652!
414
            throw new Error('rootDir is required');
×
415
        }
416
        this.rootDir = fileUtils.standardizePath(params.rootDir);
652✔
417

418
        if (!params?.outDir) {
652!
419
            throw new Error('outDir is required');
×
420
        }
421
        this.outDir = fileUtils.standardizePath(params.outDir);
652✔
422
        this.stagingDir = params.stagingDir ?? util.getStagingDir({ outDir: this.outDir });
652✔
423
        this.bsConst = params.bsConst;
652✔
424
        this.sourceDirs = (params.sourceDirs ?? [])
652✔
425
            //standardize every sourcedir
426
            .map(x => fileUtils.standardizePath(x));
108✔
427
        this.injectRaleTrackerTask = params.injectRaleTrackerTask ?? false;
652✔
428
        this.raleTrackerTaskFileLocation = params.raleTrackerTaskFileLocation;
652✔
429
        this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false;
652✔
430
        this.rdbFilesBasePath = params.rdbFilesBasePath;
652✔
431
        this.files = params.files ?? [];
652✔
432
        this.packagePath = params.packagePath;
652✔
433
        this.enhanceREPLCompletions = params.enhanceREPLCompletions;
652✔
434
    }
435
    public rootDir: string;
436
    public outDir: string;
437
    /**
438
     * The filename of the zip package that gets created from the staging folder (relative to `outDir`).
439
     * Component libraries override this with their computed out file name.
440
     */
441
    public outFile = 'roku-deploy.zip';
652✔
442
    public packagePath: string;
443
    public sourceDirs: string[];
444
    public files: Array<FileEntry>;
445
    public stagingDir: string;
446
    public fileMappings: Array<{ src: string; dest: string }>;
447

448
    /**
449
     * Absolute staging paths of every file referenced by a `<script uri="...">` tag in any staged XML
450
     * component. Roku loads these as BrightScript regardless of file extension, so they are valid
451
     * breakpoint targets even when they don't end in `.brs`. Populated during `stage()` (the single
452
     * staging-file walk in `preprocessStagingFiles`) so consumers don't have to re-scan the staging dir.
453
     */
454
    public scriptReferencedFiles = new Set<string>();
652✔
455
    public bsConst: Record<string, boolean>;
456
    public injectRaleTrackerTask: boolean;
457
    public raleTrackerTaskFileLocation: string;
458
    public injectRdbOnDeviceComponent: boolean;
459
    public rdbFilesBasePath: string;
460
    public enhanceREPLCompletions: boolean;
461
    /**
462
     * The names of the component libraries this project imports via `bs_libs_required` in its manifest.
463
     * Populated during `stage()`. Used to know which libraries' files a `Library` statement may reference.
464
     */
465
    public bsLibsRequired: string[] = [];
652✔
466

467
    /**
468
     * The names of the component libraries this project imports via `sg_component_libs_required` in its manifest.
469
     * Populated during `stage()`. Used to know which libraries' files a `Library` statement may reference.
470
     */
471
    public sgComponentLibsRequired: string[] = [];
652✔
472

473
    /**
474
     * A BrighterScript project for the stagingDir
475
     */
476
    private stagingBscProject = new BscProjectThreaded();
652✔
477

478
    //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
479
    public get postfix() {
480
        return '';
58✔
481
    }
482

483
    private logger = logger.createLogger(`[${ProjectManager.name}]`);
652✔
484

485
    public async stage() {
486
        if (!this.fileMappings) {
24✔
487
            this.fileMappings = await this.getFileMappings();
10✔
488
        }
489

490
        //copy all project files to the staging folder
491
        await rokuDeploy.stage({
24✔
492
            rootDir: this.rootDir,
493
            files: this.files,
494
            out: this.stagingDir
495
        });
496

497
        await this.preprocessStagingFiles();
24✔
498

499
        if (this.enhanceREPLCompletions) {
24✔
500
            //activate our background brighterscript ProgramBuilder now that the staging directory contains the final production project
501
            this.stagingBscProject.activate({
1✔
502
                rootDir: this.stagingDir,
503
                files: ['**/*'],
504
                watch: false,
505
                createPackage: false,
506
                deploy: false,
507
                copyToStaging: false,
508
                showDiagnosticsInConsole: false,
509
                logLevel: 'error',
510
                //this project is only used for file and scope lookups, so skip all validations since that takes a while and we don't care
511
                validate: false
512
            }).catch((e) => {
513
                this.logger.error('Error activating staging project.', e);
×
514
            });
515
        }
516

517
        //preload the original location of every file
518
        await this.resolveFileMappingsForSourceDirs();
24✔
519

520
        await this.transformManifestWithBsConst();
24✔
521

522
        await this.loadRequiredLibraryNames();
24✔
523

524
        await this.copyAndTransformRaleTrackerTask();
24✔
525

526
        await this.copyAndTransformRDB();
24✔
527
    }
528

529
    /**
530
     * Read the staged manifest's `bs_libs_required` and `sg_component_libs_required` values and store the
531
     * (comma-delimited) library names this project imports under each mechanism. The two lists are kept separate
532
     * because a `bs_libs_required` entry is only ever satisfied by a `bs_libs_provided` library, and an
533
     * `sg_component_libs_required` entry only by an `sg_component_libs_provided` library. These names are later
534
     * matched against the libraries that actually provide them so that only `Library` statements referencing
535
     * files from a required library get postfixed.
536
     */
537
    private async loadRequiredLibraryNames() {
538
        const manifestPath = s`${this.stagingDir}/manifest`;
29✔
539
        const manifestValues = await util.convertManifestToObject(manifestPath);
29✔
540
        this.bsLibsRequired = util.splitAndTrim(manifestValues?.bs_libs_required);
29✔
541
        this.sgComponentLibsRequired = util.splitAndTrim(manifestValues?.sg_component_libs_required);
29✔
542
    }
543

544
    /**
545
     * Get all of the functions available for all scopes for this file.
546
     * @param relativePath path to the file relative to rootDir
547
     * @returns
548
     */
549
    public getScopeFunctionsForFile(relativePath: string) {
550
        if (this.enhanceREPLCompletions && this.stagingBscProject?.isActivated) {
×
551
            return this.stagingBscProject.getScopeFunctionsForFile({ relativePath: relativePath });
×
552
        } else {
553
            return [];
×
554
        }
555
    }
556

557
    /**
558
     * Get the range of the scope for the given position in the file
559
     * @param relativePath path to the file relative to rootDir
560
     * @param position the position in the file to get the scope range for
561
     */
562
    public async getScopeRange(relativePath: string, position: Position) {
563
        if (this.stagingBscProject?.isActivated) {
×
564
            return this.stagingBscProject.getScopeRange({ relativePath: relativePath, position: position });
×
565
        } else {
566
            return undefined;
×
567
        }
568
    }
569

570
    /**
571
     * If the project uses sourceDirs, replace every `fileMapping.src` with its original location in sourceDirs
572
     */
573
    private resolveFileMappingsForSourceDirs() {
574
        return Promise.all([
24✔
575
            this.fileMappings.map(async x => {
576
                let stagingFilePathRelative = fileUtils.getRelativePath(this.stagingDir, x.dest);
46✔
577
                let sourceDirFilePath = await fileUtils.findFirstRelativeFile(stagingFilePathRelative, this.sourceDirs);
46✔
578
                if (sourceDirFilePath) {
46!
579
                    x.src = sourceDirFilePath;
×
580
                }
581
            })
582
        ]);
583
    }
584

585
    /**
586
     * Walk every staged file once and apply all necessary rewrites for files that were moved
587
     * from a different source location:
588
     *  - .map files: rewrite `sources` paths to be relative to the new staging location
589
     *  - .brs/.xml files: rewrite the sourceMappingURL comment path to point to the staged map
590
     */
591
    private async preprocessStagingFiles() {
592
        const srcToDestMap = new Map<string, string>();
148✔
593
        const destToSrcMap = new Map<string, string>();
148✔
594
        for (const mapping of this.fileMappings) {
148✔
595
            srcToDestMap.set(mapping.src, mapping.dest);
190✔
596
            destToSrcMap.set(mapping.dest, mapping.src);
190✔
597
        }
598

599
        //reset before re-scanning
600
        this.scriptReferencedFiles.clear();
148✔
601

602
        //walk over every file
603
        const stagedFiles: string[] = (await fastGlob('**/*', { cwd: this.stagingDir, absolute: true, onlyFiles: true }))
148✔
604
            .map((f: string) => fileUtils.standardizePath(f));
2,614✔
605

606
        await Promise.all(stagedFiles.map(async (stagingFilePath: string) => {
148✔
607
            const ext = path.extname(stagingFilePath).toLowerCase();
2,614✔
608
            const originalSrcPath = destToSrcMap.get(stagingFilePath);
2,614✔
609

610
            //.map files are handled separately (they get their own JSON read), and never need the
611
            //text-content path below. Skip maps that aren't in fileMappings (generated after staging).
612
            if (ext === '.map') {
2,614✔
613
                if (originalSrcPath) {
46✔
614
                    await this.fixSourceMapSources({
45✔
615
                        stagingMapPath: stagingFilePath,
616
                        originalMapPath: originalSrcPath
617
                    });
618
                }
619
                return;
46✔
620
            }
621

622
            //read each text file at most once and share the contents between the two consumers below:
623
            // - collectScriptReferencedFiles: runs for ALL staged xml (a component may be generated during
624
            //   staging, so it isn't necessarily in fileMappings)
625
            // - fixSourceMapComment: runs only for files that were moved from a source dir (in fileMappings)
626
            //binary files need neither, so we never read them.
627
            const isXml = ext === '.xml';
2,568✔
628
            const needsCommentFix = !!originalSrcPath;
2,568✔
629
            if (Project.binaryExtensions.has(ext) || (!isXml && !needsCommentFix)) {
2,568✔
630
                return;
2,486✔
631
            }
632

633
            let contents: string;
634
            try {
82✔
635
                contents = await fsExtra.readFile(stagingFilePath, 'utf8');
82✔
636
            } catch (e) {
637
                this.logger.debug('Error reading staged file during preprocess', { stagingFilePath, error: e });
×
638
                return;
×
639
            }
640

641
            if (isXml) {
82✔
642
                this.collectScriptReferencedFiles(stagingFilePath, contents);
14✔
643
            }
644
            if (needsCommentFix) {
82✔
645
                await this.fixSourceMapComment(stagingFilePath, originalSrcPath, srcToDestMap, contents);
75✔
646
            }
647
        }));
648
    }
649

650
    /**
651
     * Parse a staged XML file's contents for `<script uri="...">` tags and add each referenced file's
652
     * absolute staging path to {@link scriptReferencedFiles}. `pkg:/`/`libpkg:/` uris resolve from the
653
     * staging root; bare relative uris resolve from the XML file's own directory. Roku loads these as
654
     * BrightScript regardless of extension, so they are valid breakpoint targets.
655
     * @param contents the already-loaded file contents (read once by the staging walk)
656
     */
657
    private collectScriptReferencedFiles(xmlStagingPath: string, contents: string) {
658
        const scriptUriRegex = /<script\b[^>]*\buri\s*=\s*"([^"]*)"[^>]*\/?>/gi;
14✔
659
        let match: RegExpExecArray;
660
        while ((match = scriptUriRegex.exec(contents)) !== null) {
14✔
661
            const uri = match[1];
8✔
662
            const protocolIndex = uri.indexOf(':/');
8✔
663
            let absolutePath: string;
664
            if (protocolIndex >= 0) {
8✔
665
                //pkg:/ or libpkg:/ — resolve from staging root
666
                const relativePath = uri.substring(protocolIndex + 2).replace(/^\//, '');
7✔
667
                absolutePath = s`${this.stagingDir}/${relativePath}`;
7✔
668
            } else {
669
                //relative path — resolve from the XML file's directory
670
                absolutePath = s`${path.resolve(path.dirname(xmlStagingPath), uri)}`;
1✔
671
            }
672
            this.scriptReferencedFiles.add(absolutePath);
8✔
673
        }
674
    }
675

676
    /**
677
     * Per-path write locks. Async file ops interleave (e.g. `preprocessStagingFiles` fans out
678
     * tasks that can target the same staging `.map`), so we queue all writes to a given path
679
     * through a single promise chain. Entries clean themselves up once their chain is idle.
680
     */
681
    private writeLocks = new Map<string, Promise<unknown>>();
652✔
682

683
    private serializeWrite<T>(filePath: string, work: () => Promise<T>): Promise<T> {
684
        const key = fileUtils.standardizePath(filePath).toLowerCase();
105✔
685
        const prev = this.writeLocks.get(key) ?? Promise.resolve();
105✔
686
        //run work whether the prior op resolved or rejected — failures upstream shouldn't poison the chain
687
        const next = prev.then(work, work);
105✔
688
        this.writeLocks.set(key, next);
105✔
689
        //drop the entry once nothing else has chained onto it
690
        void next.catch(() => { /* swallow — caller's await sees the real error */ }).then(() => {
105✔
691
            if (this.writeLocks.get(key) === next) {
105✔
692
                this.writeLocks.delete(key);
101✔
693
            }
694
        });
695
        return next;
105✔
696
    }
697

698
    /**
699
     * Serialized wrapper around `fsExtra.writeFile`. Use in place of `fsExtra.writeFile` anywhere
700
     * a path might also be written by another concurrent task in this Project.
701
     */
702
    private writeFile(filePath: string, data: Parameters<typeof fsExtra.writeFile>[1], options?: Parameters<typeof fsExtra.writeFile>[2]) {
703
        return this.serializeWrite(filePath, () => fsExtra.writeFile(filePath, data, options));
64✔
704
    }
705

706
    /**
707
     * Serialized wrapper around `fsExtra.copyFile`. The dest path is the one we serialize on,
708
     * since that's what gets written.
709
     */
710
    private copyFile(srcPath: string, destPath: string) {
711
        return this.serializeWrite(destPath, () => fsExtra.copyFile(srcPath, destPath));
41✔
712
    }
713

714
    /**
715
     * Rewrite the `sources` paths in a staged .map file so they are relative to the map's
716
     * new staging location rather than the original source directory.
717
     */
718
    private async fixSourceMapSources(params: { stagingMapPath: string; originalMapPath: string }) {
719
        const { stagingMapPath, originalMapPath } = params;
86✔
720

721
        try {
86✔
722
            const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload;
86✔
723
            if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) {
85✔
724
                return;
56✔
725
            }
726
            // Resolve sources relative to original map's base dir (honoring sourceRoot if present)
727
            const originalBaseDir = path.resolve(
29✔
728
                //sourceRoot should resolve relative to originalMapDir, or keep as-is when absolute path
729
                path.dirname(originalMapPath),
730
                sourceMap.sourceRoot ?? ''
87✔
731
            );
732

733
            const stagingMapDir = path.dirname(stagingMapPath);
29✔
734

735
            sourceMap.sources = sourceMap.sources.map((source) => {
29✔
736
                const absoluteSourcePath = path.resolve(originalBaseDir, source);
30✔
737
                return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath));
30✔
738
            });
739

740
            // Clear sourceRoot since sources are now relative to the map file's new location
741
            delete sourceMap.sourceRoot;
29✔
742

743
            await this.writeFile(stagingMapPath, JSON.stringify(sourceMap));
29✔
744
        } catch (e) {
745
            this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e);
1✔
746
        }
747
    }
748

749

750
    public static readonly binaryExtensions = new Set([
2✔
751
        // images
752
        '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg',
753
        '.heic', '.heif', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng',
754
        // video
755
        '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg',
756
        '.m2v', '.ts', '.mts', '.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
757
        // audio
758
        '.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
759
        // fonts
760
        '.ttf', '.otf', '.woff', '.woff2', '.eot',
761
        // archives / binary containers
762
        '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar', '.pkg', '.exe', '.dll', '.so',
763
        // documents / other binary formats
764
        '.pdf', '.psd', '.ai', '.eps', '.indd',
765
        // roku-specific
766
        '.roku', '.rdb', '.squashfs'
767
    ]);
768

769
    /**
770
     * Extracts the sourceMappingURL comment from the given file contents.
771
     *
772
     * `match[3]` is the path (which may be relative or absolute)
773
     * @param contents
774
     * @returns
775
     */
776
    public static getSourceMapComment(contents: string) {
777

778
        //https://regex101.com/r/FMRJNy/2
779
        const commentMatch = [
120✔
780
            ...contents.matchAll(/^([ \t]*(?:'|<!--)?[ \t]*)((?:\/\/)?[ \t]*[#@][ \t]*sourceMappingURL=(.+\b))(?:|-->)?/gm)
781
        ].pop();
782
        if (commentMatch) {
120✔
783
            return {
59✔
784
                /**
785
                 * The entire matched comment, including any leading whitespace and comment characters (e.g. `'` or `<!--`), which should be preserved when rewriting the comment
786
                 */
787
                fullMatch: commentMatch?.[0],
177!
788
                /**
789
                 * The leading whitespace and comment characters (e.g. `'` or `<!--`) before the actual `sourceMappingURL` text, which should be preserved when rewriting the comment
790
                 */
791
                leadingInfo: commentMatch?.[1],
177!
792
                /**
793
                 * The entire comment text without the leadingInfo (e.g. `//# sourceMappingURL=someFile.map`)
794
                 */
795
                wholeComment: commentMatch?.[2],
177!
796
                /**
797
                 * The path to the source map file (e.g. `someFile.map`)
798
                 */
799
                mapPath: commentMatch?.[3]
177!
800
            };
801
        } else {
802
            return undefined;
61✔
803
        }
804
    }
805

806
    /**
807
     * Rewrite the sourceMappingURL comment in a staged .brs or .xml file so the path points
808
     * to the map file's new staging location.
809
     *
810
     * Recognised comment forms (# and legacy @ are both accepted; // is optional for brs/xml):
811
     *   BRS:   ' [//] [#|@] sourceMappingURL=<path>
812
     *   XML:   <!-- [//] [#|@] sourceMappingURL=<path> -->
813
     *   other: // \s* [#|@] sourceMappingURL=<path>
814
     *
815
     * When rewriting, the canonical modern form is always written:
816
     *   BRS:   '//# sourceMappingURL=<path>
817
     *   XML:   <!--//# sourceMappingURL=<path> -->
818
     *   other: //# sourceMappingURL=<path>
819
     */
820
    private async fixSourceMapComment(stagingFilePath: string, originalSrcPath: string, srcToDestMap: Map<string, string>, contents: string) {
821
        try {
75✔
822
            const commentMatch = Project.getSourceMapComment(contents);
75✔
823

824
            let absoluteMapPath: string;
825

826
            if (commentMatch) {
75✔
827
                absoluteMapPath = fileUtils.standardizePath(
34✔
828
                    path.isAbsolute(commentMatch.mapPath)
34✔
829
                        ? commentMatch.mapPath
830
                        : path.resolve(path.dirname(originalSrcPath), commentMatch.mapPath)
831
                );
832

833
                //copy the sourcemap right next to our file in staging
834
                absoluteMapPath = await this.colocateSourceMap({
34✔
835
                    absoluteMapPath: absoluteMapPath,
836
                    stagingFilePath: stagingFilePath
837
                });
838

839
            } else {
840
                // No comment — check if a colocated map exists next to the original source file
841
                absoluteMapPath = fileUtils.standardizePath(originalSrcPath + '.map');
41✔
842

843
                //there is no colocated map next to the original source file
844
                if (!await fsExtra.pathExists(absoluteMapPath)) {
41✔
845
                    return;
34✔
846
                }
847

848
                //copy the sourcemap right next to our file in staging — the debugger will find it automatically
849
                await this.colocateSourceMap({
7✔
850
                    absoluteMapPath: absoluteMapPath,
851
                    stagingFilePath: stagingFilePath
852
                });
853
                return;
7✔
854
            }
855

856
            // If the map was also staged, point at its new location; otherwise point back at the original
857
            const mapTarget = srcToDestMap.get(absoluteMapPath) ?? absoluteMapPath;
34!
858
            const newRelativePath = fileUtils.standardizePath(
34✔
859
                path.relative(path.dirname(stagingFilePath), mapTarget)
860
            );
861

862
            const newComment = `${commentMatch.leadingInfo.trimEnd()}//# sourceMappingURL=${newRelativePath}`;
34✔
863
            contents = contents.replace(commentMatch.fullMatch, newComment);
34✔
864
            await this.writeFile(stagingFilePath, contents, 'utf8');
34✔
865
        } catch (e) {
866
            this.logger.error(`Error updating sourceMappingURL comment in '${stagingFilePath}'`, e);
×
867
        }
868
    }
869

870
    private async colocateSourceMap(options: { stagingFilePath: string; absoluteMapPath: string }) {
871
        //copy the sourcemap right next to our file (skip if it's already there)
872
        const stagingMapPath = `${options.stagingFilePath}.map`;
41✔
873
        if (fileUtils.standardizePath(options.absoluteMapPath) !== fileUtils.standardizePath(stagingMapPath)) {
41!
874
            await this.copyFile(options.absoluteMapPath, stagingMapPath);
41✔
875
        }
876
        await this.fixSourceMapSources({
41✔
877
            stagingMapPath: stagingMapPath,
878
            originalMapPath: options.absoluteMapPath
879
        });
880
        return stagingMapPath;
41✔
881
    }
882

883

884
    /**
885
     * Apply the bsConst transformations to the manifest file for this project
886
     */
887
    public async transformManifestWithBsConst() {
888
        if (this.bsConst) {
24✔
889
            let manifestPath = s`${this.stagingDir}/manifest`;
1✔
890
            if (await fsExtra.pathExists(manifestPath)) {
1!
891
                // Update the bs_const values in the manifest in the staging folder before side loading the channel
892
                let fileContents = (await fsExtra.readFile(manifestPath)).toString();
1✔
893
                fileContents = this.updateManifestBsConsts(this.bsConst, fileContents);
1✔
894
                await this.writeFile(manifestPath, fileContents);
1✔
895
            }
896
        }
897
    }
898

899
    public updateManifestBsConsts(consts: Record<string, boolean>, fileContents: string): string {
900
        let bsConstLine: string;
901
        let missingConsts: string[] = [];
5✔
902
        let lines = fileContents.split(/\r?\n/g);
5✔
903

904
        let newLine: string;
905
        //loop through the lines until we find the bs_const line if it exists
906
        for (const line of lines) {
5✔
907
            if (line.toLowerCase().startsWith('bs_const')) {
53✔
908
                bsConstLine = line;
5✔
909
                newLine = line;
5✔
910
                break;
5✔
911
            }
912
        }
913

914
        if (bsConstLine) {
5!
915
            // update the consts in the manifest and check for missing consts
916
            missingConsts = Object.keys(consts).reduce((results, key) => {
5✔
917
                let match = new RegExp('(' + key + '\\s*=\\s*[true|false]+[^\\S\\r\\n]*\)', 'i').exec(bsConstLine);
7✔
918
                if (match) {
7!
919
                    newLine = newLine.replace(match[1], `${key}=${consts[key].toString()}`);
7✔
920
                } else {
921
                    results.push(key);
×
922
                }
923

924
                return results;
7✔
925
            }, []);
926

927
            // check for consts that where not in the manifest
928
            if (missingConsts.length > 0) {
5!
929
                throw new Error(`The following bs_const keys were not defined in the channel's manifest:\n\n${missingConsts.join(',\n')}`);
×
930
            } else {
931
                // update the manifest contents
932
                return fileContents.replace(bsConstLine, newLine);
5✔
933
            }
934
        } else {
935
            throw new Error('bs_const was defined in the launch.json but not in the channel\'s manifest');
×
936
        }
937
    }
938

939
    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✔
940
    public static RALE_TRACKER_ENTRY = 'vscode_rale_tracker_entry';
2✔
941
    /**
942
     * Search the project files for the comment "' vscode_rale_tracker_entry" and replace it with the code needed to start the TrackerTask.
943
     */
944
    public async copyAndTransformRaleTrackerTask() {
945
        // inject the tracker task into the staging files if we have everything we need
946
        if (!this.injectRaleTrackerTask || !this.raleTrackerTaskFileLocation) {
37✔
947
            return;
24✔
948
        }
949
        try {
13✔
950
            await fsExtra.copy(this.raleTrackerTaskFileLocation, s`${this.stagingDir}/components/TrackerTask.xml`);
13✔
951
            this.logger.log('Tracker task successfully injected');
13✔
952
            // Search for the tracker task entry injection point
953
            const trackerReplacementResult = await replaceInFile({
13✔
954
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
955
                from: new RegExp(`^.*'\\s*${Project.RALE_TRACKER_ENTRY}.*$`, 'mig'),
956
                to: (match: string) => {
957
                    // Strip off the comment
958
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
959
                    if (/[\S]/.exec(startOfLine)) {
12✔
960
                        // There was some form of code before the tracker entry
961
                        // append and use single line syntax
962
                        startOfLine += ': ';
6✔
963
                    }
964
                    return `${startOfLine}${Project.RALE_TRACKER_TASK_CODE}`;
12✔
965
                }
966
            });
967
            const injectedFiles = trackerReplacementResult
13✔
968
                .filter(result => result.hasChanged)
26✔
969
                .map(result => result.file);
12✔
970

971
            if (injectedFiles.length === 0) {
13✔
972
                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✔
973
            }
974
        } catch (err) {
975
            console.error(err);
×
976
        }
977
    }
978

979
    public static RDB_ODC_NODE_CODE = `if true = CreateObject("roAppInfo").IsDev() then m.vscode_rdb_odc_node = createObject("roSGNode", "RTA_OnDeviceComponent") ' RDB OnDeviceComponent`;
2✔
980
    public static RDB_ODC_ENTRY = 'vscode_rdb_on_device_component_entry';
2✔
981
    /**
982
     * 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.
983
     */
984
    public async copyAndTransformRDB() {
985
        // inject the on device component into the staging files if we have everything we need
986
        if (!this.injectRdbOnDeviceComponent || !this.rdbFilesBasePath) {
40✔
987
            return;
25✔
988
        }
989
        try {
15✔
990

991
            let files: string[] = await fastGlob(
15✔
992
                //fast-glob requires forward slashes, so convert any backslashes in the provided path to forward slashes before globbing
993
                `${this.rdbFilesBasePath}/**/*`.replace(/[\\/]+/g, '/'),
994
                {
995
                    cwd: './',
996
                    absolute: false,
997
                    followSymbolicLinks: true
998
                }
999
            );
1000
            for (let filePathAbsolute of files) {
15✔
1001
                const promises = [];
28✔
1002
                //only include files (i.e. skip directories)
1003
                if (await util.isFile(filePathAbsolute)) {
28!
1004
                    const relativePath = s`${filePathAbsolute}`.replace(s`${this.rdbFilesBasePath}`, '');
28✔
1005
                    const destinationPath = s`${this.stagingDir}/${relativePath}`;
28✔
1006
                    promises.push(fsExtra.copy(filePathAbsolute, destinationPath));
28✔
1007
                }
1008
                await Promise.all(promises);
28✔
1009
                this.logger.log('RDB OnDeviceComponent successfully injected');
28✔
1010
            }
1011

1012
            // Search for the tracker task entry injection point
1013
            const replacementResult = await replaceInFile({
15✔
1014
                files: `${this.stagingDir}/**/*.+(xml|brs)`,
1015
                from: new RegExp(`^.*'\\s*${Project.RDB_ODC_ENTRY}.*$`, 'mig'),
1016
                to: (match: string) => {
1017
                    // Strip off the comment
1018
                    let startOfLine = match.substring(0, match.indexOf(`'`));
12✔
1019
                    if (/[\S]/.exec(startOfLine)) {
12✔
1020
                        // There was some form of code before the tracker entry
1021
                        // append and use single line syntax
1022
                        startOfLine += ': ';
6✔
1023
                    }
1024
                    return `${startOfLine}${Project.RDB_ODC_NODE_CODE}`;
12✔
1025
                }
1026
            });
1027
            const injectedFiles = replacementResult
14✔
1028
                .filter(result => result.hasChanged)
42✔
1029
                .map(result => result.file);
12✔
1030

1031
            if (injectedFiles.length === 0) {
14✔
1032
                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✔
1033
            }
1034
        } catch (err) {
1035
            console.error(err);
1✔
1036
        }
1037
    }
1038

1039
    /**
1040
     *
1041
     * @param stagingPath
1042
     */
1043
    public async zipPackage(params: { retainStagingFolder: boolean }) {
1044
        let packagePath = this.packagePath;
3✔
1045
        if (!this.packagePath) {
3✔
1046
            //make sure the output folder exists
1047
            await fsExtra.ensureDir(this.outDir);
2✔
1048

1049
            packagePath = util.getOutputZipPath({ outDir: this.outDir, outFile: this.outFile });
2✔
1050
        }
1051

1052
        //ensure the manifest file exists in the staging folder
1053
        if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${this.stagingDir}/manifest`)) {
3!
NEW
1054
            throw new Error(`Cannot zip package: missing manifest file in "${this.stagingDir}"`);
×
1055
        }
1056

1057
        // create a zip of the staging folder
1058
        await rokuDeploy.zip({
3✔
1059
            dir: this.stagingDir,
1060
            out: packagePath,
1061
            files: [
1062
                '**/*',
1063
                //exclude sourcemap files (they're large and can't be parsed on-device anyway...)
1064
                '!**/*.map'
1065
            ]
1066
        });
1067

1068
        //delete the staging folder unless told to retain it.
1069
        if (params.retainStagingFolder !== true) {
3!
NEW
1070
            await fsExtra.remove(this.stagingDir);
×
1071
        }
1072
    }
1073

1074
    /**
1075
     * Get the file paths from roku-deploy, and ensure the dest paths are absolute
1076
     * (`dest` paths are relative in later versions of roku-deploy)
1077
     */
1078
    protected async getFileMappings() {
1079
        let fileMappings = await rokuDeploy.getFilePaths({ files: this.files, rootDir: this.rootDir });
25✔
1080
        for (let fileMapping of fileMappings) {
25✔
1081
            fileMapping.dest = s`${this.stagingDir}/${fileMapping.dest}`;
48✔
1082
        }
1083
        return fileMappings;
25✔
1084
    }
1085

1086
    public dispose() {
1087
        this.stagingBscProject?.dispose?.();
×
1088
    }
1089
}
1090

1091
export interface ComponentLibraryConstructorParams extends AddProjectParams {
1092
    outFile: string;
1093
    libraryIndex: number;
1094
    install?: boolean;
1095
    enablePostfix?: boolean;
1096
}
1097

1098
export class ComponentLibraryProject extends Project {
2✔
1099
    constructor(params: ComponentLibraryConstructorParams) {
1100
        super(params);
320✔
1101
        this.outFile = params.outFile;
320✔
1102
        this.libraryIndex = params.libraryIndex;
320✔
1103
        this.install = params.install ?? false;
320✔
1104
        this.enablePostfix = params.enablePostfix ?? true;
320✔
1105
    }
1106
    public libraryIndex: number;
1107
    public install: boolean;
1108
    /**
1109
     * Should this component library's `.brs` files be renamed with a `__lib<index>` postfix during staging?
1110
     * Postfixing is how the debugger maps a device-reported file path back to the component library it came
1111
     * from. When disabled, file names are left untouched (useful when a library loads files by a fixed name
1112
     * at runtime), at the cost of degraded source mapping for this library's files while debugging.
1113
     */
1114
    public enablePostfix: boolean;
1115
    /**
1116
     * The name of the component library that this project represents. This is loaded during `this.computeOutFileName`
1117
     */
1118
    public name: string;
1119

1120
    /**
1121
     * The library names this project broadcasts via `sg_component_libs_provided` in its manifest. Only a consumer's
1122
     * `sg_component_libs_required` entries may resolve against these. Loaded during `this.computeOutFileName`.
1123
     */
1124
    public sgComponentLibsProvided: string[] = [];
320✔
1125

1126
    /**
1127
     * The library names this project broadcasts via `bs_libs_provided` in its manifest. Only a consumer's
1128
     * `bs_libs_required` entries may resolve against these. Loaded during `this.computeOutFileName`.
1129
     *
1130
     * A library may broadcast itself under both mechanisms by declaring both manifest keys, in which case this
1131
     * and `sgComponentLibsProvided` are both populated.
1132
     */
1133
    public bsLibsProvided: string[] = [];
320✔
1134

1135
    /**
1136
     * Takes a component Library and checks the outFile for replaceable values pulled from the libraries manifest
1137
     * @param manifestPath the path to the manifest file to check
1138
     */
1139
    private async computeOutFileName(manifestPath: string) {
1140
        let regexp = /\$\{([\w\d_]*)\}/;
16✔
1141
        let renamingMatch: RegExpExecArray;
1142
        let manifestValues = await util.convertManifestToObject(manifestPath);
16✔
1143
        if (!manifestValues) {
16✔
1144
            throw new Error(`Cannot find manifest file at "${manifestPath}"\n\nCould not complete automatic component library naming.`);
1✔
1145
        }
1146

1147
        //load the component libary name from the manifest
1148
        this.name = manifestValues.sg_component_libs_provided || manifestValues.bs_libs_provided;
15✔
1149

1150
        //track each `provided` mechanism separately so `Library` reference postfixing never matches a
1151
        //`bs_libs_required` against an `sg_component_libs_provided` (or vice versa)
1152
        this.sgComponentLibsProvided = util.splitAndTrim(manifestValues.sg_component_libs_provided);
15✔
1153
        this.bsLibsProvided = util.splitAndTrim(manifestValues.bs_libs_provided);
15✔
1154

1155
        // search the outFile for replaceable values such as ${title}
1156
        while ((renamingMatch = regexp.exec(this.outFile))) {
15✔
1157

1158
            // replace the replaceable key with the manifest value
1159
            let manifestVariableName = renamingMatch[1];
5✔
1160
            let manifestVariableValue = manifestValues[manifestVariableName];
5✔
1161
            if (manifestVariableValue) {
5!
1162
                this.outFile = this.outFile.replace(renamingMatch[0], manifestVariableValue);
5✔
1163
            } else {
1164
                throw new Error(`Cannot find manifest value:\n"${manifestVariableName}"\n\nCould not complete automatic component library naming.`);
×
1165
            }
1166
        }
1167
    }
1168

1169
    public async stage() {
1170
        /*
1171
         Compute the file mappings now (i.e. don't let the parent class compute them).
1172
         This must be done BEFORE finding the manifest file location.
1173
         */
1174
        this.fileMappings = await this.getFileMappings();
15✔
1175

1176
        let expectedManifestDestPath = fileUtils.standardizePath(`${this.stagingDir}/manifest`).toLowerCase();
15✔
1177
        //find the file entry with the `dest` value of `${stagingDir}/manifest` (case insensitive)
1178
        let manifestFileEntry = this.fileMappings.find(x => x.dest.toLowerCase() === expectedManifestDestPath);
15✔
1179
        if (manifestFileEntry) {
15!
1180
            //read the manifest from `src` since nothing has been copied to staging yet
1181
            await this.computeOutFileName(manifestFileEntry.src);
15✔
1182
        } else {
1183
            throw new Error(`Could not find manifest path for component library at '${this.rootDir}'`);
×
1184
        }
1185
        let fileNameWithoutExtension = path.basename(this.outFile, path.extname(this.outFile));
15✔
1186

1187
        let defaultStagingDir = this.stagingDir;
15✔
1188

1189
        //compute the staging folder path.
1190
        this.stagingDir = s`${this.outDir}/${fileNameWithoutExtension}`;
15✔
1191

1192
        /*
1193
          The fileMappings were created using the default stagingDir (because we need the manifest path
1194
          to compute the out file name and staging path), so we need to replace the default stagingDir
1195
          with the actual stagingDir.
1196
         */
1197
        for (let fileMapping of this.fileMappings) {
15✔
1198
            fileMapping.dest = fileUtils.replaceCaseInsensitive(fileMapping.dest, defaultStagingDir, this.stagingDir);
16✔
1199
        }
1200

1201
        return super.stage();
15✔
1202
    }
1203

1204
    /**
1205
     * The text used as a postfix for every brs file so we can accurately track the location of the files
1206
     * back to their original component library whenever the debugger truncates the file path.
1207
     */
1208
    public get postfix() {
1209
        //when postfixing is disabled, return an empty postfix so all postfix-aware logic (file renaming,
1210
        //breakpoint path matching, source-location resolution) becomes a no-op for this library
1211
        return this.enablePostfix ? `${componentLibraryPostfix}${this.libraryIndex}` : '';
17✔
1212
    }
1213

1214
    public async postfixFiles() {
1215
        //postfixing disabled for this library; leave file names and `uri=` references untouched
1216
        if (!this.enablePostfix) {
5✔
1217
            return;
1✔
1218
        }
1219
        let pathDetails = {};
4✔
1220
        await Promise.all(this.fileMappings.map(async (fileMapping) => {
4✔
1221
            let relativePath = fileUtils.removeLeadingSlash(
×
1222
                fileUtils.getRelativePath(this.stagingDir, fileMapping.dest)
1223
            );
1224
            let postfixedPath = fileUtils.postfixFilePath(relativePath, this.postfix, ['.brs']);
×
1225
            if (postfixedPath !== relativePath) {
×
1226
                // Rename the brs files to include the postfix namespacing tag
1227
                await fsExtra.move(fileMapping.dest, path.join(this.stagingDir, postfixedPath));
×
1228
                // Add to the map of original paths and the new paths
1229
                pathDetails[postfixedPath] = relativePath;
×
1230
            }
1231
        }));
1232

1233
        // Update all the file name references in the library to the new file names
1234
        await replaceInFile({
4✔
1235
            files: [
1236
                path.join(this.stagingDir, '**/*.xml'),
1237
                path.join(this.stagingDir, '**/*.brs')
1238
            ],
1239
            //do not throw an error whenever we match zero files with the given glob (e.g. if there are no brs files or no xml files in the library)
1240
            allowEmptyPaths: true,
1241
            from: /uri\s*=\s*"(.+)\.brs"/gi,
1242
            to: (match: string) => {
1243
                // only alter file ending if it is a) pkg:/ url or b) relative url
1244
                let isPkgUrl = !!/^uri\s*=\s*"(pkg:|libpkg:)\//i.exec(match);
9✔
1245
                let isRelativeUrl = !/:\//i.exec(match);
9✔
1246
                if (isPkgUrl || isRelativeUrl) {
9✔
1247
                    return match.replace('.brs', this.postfix + '.brs');
7✔
1248
                } else {
1249
                    return match;
2✔
1250
                }
1251
            }
1252
        });
1253
    }
1254

1255
    /**
1256
     * The set of `.brs` file names (basename only, e.g. `LibAlpha.brs`) that this component library exports
1257
     * from its `libsource` folder. These are the only files a consuming project may reference in a `Library`
1258
     * statement, so only files under `libsource` are eligible - any other staged `.brs` (components, source, etc.)
1259
     * is NOT a library export and must never be rewritten. Computed from `fileMappings`, which retain the
1260
     * original (pre-postfix) file names.
1261
     */
1262
    public getExportedLibraryFileNames(): string[] {
1263
        return (this.fileMappings ?? [])
54!
1264
            //only files inside a `libsource` folder are library exports
1265
            .filter(fileMapping => /(^|[\\/])libsource[\\/]/i.test(fileMapping.dest))
68✔
1266
            .map(fileMapping => path.basename(fileMapping.dest))
66✔
1267
            .filter(fileName => /\.brs$/i.test(fileName));
66✔
1268
    }
1269
}
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