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

rokucommunity / brighterscript / #14044

20 Mar 2025 07:09PM UTC coverage: 87.163% (-2.0%) from 89.117%
#14044

push

web-flow
Merge e33b1f944 into 0eceb0830

13257 of 16072 branches covered (82.49%)

Branch coverage included in aggregate %.

1163 of 1279 new or added lines in 24 files covered. (90.93%)

802 existing lines in 52 files now uncovered.

14323 of 15570 relevant lines covered (91.99%)

21312.85 hits per line

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

69.82
/src/ProgramBuilder.ts
1
import * as debounce from 'debounce-promise';
1✔
2
import * as path from 'path';
1✔
3
import { rokuDeploy } from 'roku-deploy';
1✔
4
import type { LogLevel as RokuDeployLogLevel } from 'roku-deploy/dist/Logger';
5
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
6
import type { BsDiagnostic, FileObj, FileResolver } from './interfaces';
7
import { Program } from './Program';
1✔
8
import { standardizePath as s, util } from './util';
1✔
9
import { Watcher } from './Watcher';
1✔
10
import { DiagnosticSeverity } from 'vscode-languageserver';
1✔
11
import type { Logger } from './logging';
12
import { LogLevel, createLogger } from './logging';
1✔
13
import PluginInterface from './PluginInterface';
1✔
14
import * as diagnosticUtils from './diagnosticUtils';
1✔
15
import * as fsExtra from 'fs-extra';
1✔
16
import * as requireRelative from 'require-relative';
1✔
17
import { Throttler } from './Throttler';
1✔
18
import type { BrsFile } from './files/BrsFile';
19
import { DiagnosticManager } from './DiagnosticManager';
1✔
20

21
/**
22
 * A runner class that handles
23
 */
24
export class ProgramBuilder {
1✔
25

26
    public constructor(
27
        options?: {
28
            logger?: Logger;
29
        }
30
    ) {
31
        this.logger = options?.logger ?? createLogger();
113✔
32
        this.plugins = new PluginInterface([], { logger: this.logger });
113✔
33

34
        //add the default file resolver (used to load source file contents).
35
        this.addFileResolver((filePath) => {
113✔
36
            return fsExtra.readFile(filePath);
81✔
37
        });
38
    }
39
    /**
40
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
41
     */
42
    public allowConsoleClearing = true;
113✔
43

44
    public options: FinalizedBsConfig = util.normalizeConfig({});
113✔
45
    private isRunning = false;
113✔
46
    private watcher: Watcher | undefined;
47
    public program: Program | undefined;
48
    public logger: Logger;
49
    public plugins: PluginInterface;
50
    private fileResolvers = [] as FileResolver[];
113✔
51

52
    /**
53
     * Add file resolvers that will be able to provide file contents before loading from the file system
54
     * @param fileResolvers a list of file resolvers
55
     */
56
    public addFileResolver(...fileResolvers: FileResolver[]) {
57
        this.fileResolvers.push(...fileResolvers);
113✔
58
    }
59

60
    /**
61
     * Get the contents of the specified file as a string.
62
     * This walks backwards through the file resolvers until we get a value.
63
     * This allow the language server to provide file contents directly from memory.
64
     */
65
    public async getFileContents(srcPath: string) {
66
        srcPath = s`${srcPath}`;
81✔
67
        let reversedResolvers = [...this.fileResolvers].reverse();
81✔
68
        for (let fileResolver of reversedResolvers) {
81✔
69
            let result = await fileResolver(srcPath);
86✔
70
            if (typeof result === 'string' || Buffer.isBuffer(result)) {
86✔
71
                return result;
81✔
72
            }
73
        }
UNCOV
74
        throw new Error(`Could not load file "${srcPath}"`);
×
75
    }
76

77
    public diagnostics = new DiagnosticManager();
113✔
78

79
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
UNCOV
80
        if (!this.program) {
×
UNCOV
81
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
82
        }
UNCOV
83
        if (!diagnostic.location) {
×
UNCOV
84
            diagnostic.location = {
×
85
                uri: util.pathToUri(srcPath),
86
                range: util.createRange(0, 0, 0, 0)
87
            };
88
        } else {
UNCOV
89
            diagnostic.location.uri = util.pathToUri(srcPath);
×
90
        }
UNCOV
91
        this.diagnostics.register(<any>diagnostic, { tags: ['ProgramBuilder'] });
×
92
    }
93

94
    public getDiagnostics(): BsDiagnostic[] {
95
        return this.diagnostics.getDiagnostics();
220✔
96
    }
97

98
    /**
99
     * Load the project and all the files, but don't run the validation, transpile, or watch cycles
100
     */
101
    public async load(options: BsConfig) {
102
        try {
96✔
103
            this.options = util.normalizeAndResolveConfig(options);
96✔
104
            if (this.options?.logLevel !== undefined) {
94!
105
                this.logger.logLevel = this.options?.logLevel;
94!
106
            }
107

108
            if (this.options.noProject) {
94!
UNCOV
109
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
110
            } else if (this.options.project) {
94✔
111
                this.logger.log(`Using config file: "${this.options.project}"`);
35✔
112
            } else {
113
                this.logger.log(`No bsconfig.json file found, using default options`);
59✔
114
            }
115
            this.loadRequires();
94✔
116
            this.loadPlugins();
94✔
117
        } catch (e: any) {
118
            //For now, just use a default options object so we have a functioning program
119
            this.options = util.normalizeConfig({
2✔
120
                showDiagnosticsInConsole: options?.showDiagnosticsInConsole
6!
121
            });
122

123
            if (e?.location && e.message && e.code) {
2!
124
                let err = e as BsDiagnostic;
2✔
125
                this.diagnostics.register(err);
2✔
126
            } else {
127
                //if this is not a diagnostic, something else is wrong...
UNCOV
128
                throw e;
×
129
            }
130

131
            this.printDiagnostics();
2✔
132
        }
133
        this.logger.logLevel = this.options.logLevel;
96✔
134

135
        this.createProgram();
96✔
136

137
        //parse every file in the entire project
138
        await this.loadFiles();
96✔
139
    }
140

141
    public async run(options: BsConfig & {
142
        /**
143
         * Should validation run? Default is `true`. You must set exlicitly to `false` to disable.
144
         * @deprecated this is an experimental flag, and its behavior may change in a future release
145
         * @default true
146
         */
147
        validate?: boolean;
148
    }) {
149
        if (options?.logLevel) {
96✔
150
            this.logger.logLevel = options.logLevel;
4✔
151
        }
152

153
        if (this.isRunning) {
96!
UNCOV
154
            throw new Error('Server is already running');
×
155
        }
156
        this.isRunning = true;
96✔
157

158
        await this.load(options);
96✔
159

160
        if (this.options.watch) {
96!
UNCOV
161
            this.logger.log('Starting compilation in watch mode...');
×
NEW
162
            await this.runOnce({
×
163
                validate: options?.validate
×
164
            });
UNCOV
165
            this.enableWatchMode();
×
166
        } else {
167
            await this.runOnce({
96✔
168
                validate: options?.validate
288✔
169
            });
170
        }
171
    }
172

173
    protected createProgram() {
174
        this.program = new Program(this.options, this.logger, this.plugins, this.diagnostics);
98✔
175

176
        this.plugins.emit('afterProgramCreate', {
98✔
177
            builder: this,
178
            program: this.program
179
        });
180

181
        return this.program;
98✔
182
    }
183

184
    protected loadPlugins() {
185
        const cwd = this.options.cwd ?? process.cwd();
94!
186
        const plugins = util.loadPlugins(
94✔
187
            cwd,
188
            this.options.plugins ?? [],
282!
UNCOV
189
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
190
        );
191
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins);
94!
192
        for (let plugin of plugins) {
94✔
193
            this.plugins.add(plugin);
1✔
194
        }
195

196
        this.plugins.emit('beforeProgramCreate', {
94✔
197
            builder: this
198
        });
199
    }
200

201
    /**
202
     * `require()` every options.require path
203
     */
204
    protected loadRequires() {
205
        for (const dep of this.options.require ?? []) {
94✔
206
            requireRelative(dep, this.options.cwd);
2✔
207
        }
208
    }
209

210
    private clearConsole() {
211
        if (this.allowConsoleClearing) {
93!
212
            util.clearConsole();
93✔
213
        }
214
    }
215

216
    /**
217
     * A handle for the watch mode interval that keeps the process alive.
218
     * We need this so we can clear it if the builder is disposed
219
     */
220
    private watchInterval: NodeJS.Timer | undefined;
221

222
    public enableWatchMode() {
UNCOV
223
        this.watcher = new Watcher(this.options);
×
UNCOV
224
        if (this.watchInterval) {
×
UNCOV
225
            clearInterval(this.watchInterval);
×
226
        }
227
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
UNCOV
228
        this.watchInterval = setInterval(() => { }, 1073741824);
×
229

230
        //clear the console
UNCOV
231
        this.clearConsole();
×
232

UNCOV
233
        let fileObjects = rokuDeploy.normalizeFilesArray(this.options.files ? this.options.files : []);
×
234

235
        //add each set of files to the file watcher
UNCOV
236
        for (let fileObject of fileObjects) {
×
UNCOV
237
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
UNCOV
238
            this.watcher.watch(src);
×
239
        }
240

UNCOV
241
        this.logger.log('Watching for file changes...');
×
242

UNCOV
243
        let debouncedRunOnce = debounce(async () => {
×
UNCOV
244
            this.logger.log('File change detected. Starting incremental compilation...');
×
UNCOV
245
            await this.runOnce();
×
UNCOV
246
            this.logger.log(`Watching for file changes.`);
×
247
        }, 50);
248

249
        //on any file watcher event
250
        this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises
×
251
            if (!this.program) {
×
252
                throw new Error('Internal invariant exception: somehow file watcher ran before `ProgramBuilder.run()`');
×
253
            }
UNCOV
254
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
255
            if (event === 'add' || event === 'change') {
×
UNCOV
256
                const fileObj = {
×
257
                    src: thePath,
258
                    dest: rokuDeploy.getDestPath(
259
                        thePath,
260
                        this.program.options.files,
261
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
262
                        util.driveLetterToLower(this.rootDir)
263
                    )
264
                };
265
                this.program.setFile(
×
266
                    fileObj,
267
                    await this.getFileContents(fileObj.src)
268
                );
UNCOV
269
            } else if (event === 'unlink') {
×
270
                this.program.removeFile(thePath);
×
271
            }
272
            //wait for change events to settle, and then execute `run`
273
            await debouncedRunOnce();
×
274
        });
275
    }
276

277
    /**
278
     * The rootDir for this program.
279
     */
280
    public get rootDir() {
281
        if (!this.program) {
×
282
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
283
        }
UNCOV
284
        return this.program.options.rootDir;
×
285
    }
286

287
    /**
288
     * A method that is used to cancel a previous run task.
289
     * Does nothing if previous run has completed or was already canceled
290
     */
291
    private cancelLastRun = () => {
113✔
292
        return Promise.resolve();
93✔
293
    };
294

295
    /**
296
     * Run the entire process exactly one time.
297
     */
298
    private runOnce(options?: { validate?: boolean }) {
299
        //clear the console
300
        this.clearConsole();
93✔
301
        let cancellationToken = { isCanceled: false };
93✔
302
        //wait for the previous run to complete
303
        let runPromise = this.cancelLastRun().then(() => {
93✔
304
            //start the new run
305
            return this._runOnce({
93✔
306
                cancellationToken: cancellationToken,
307
                validate: options?.validate
279!
308
            });
309
        }) as any;
310

311
        //a function used to cancel this run
312
        this.cancelLastRun = () => {
93✔
UNCOV
313
            cancellationToken.isCanceled = true;
×
314
            return runPromise;
×
315
        };
316
        return runPromise;
93✔
317
    }
318

319
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
320
        if (this.options?.showDiagnosticsInConsole === false) {
101!
321
            return;
85✔
322
        }
323
        if (!diagnostics) {
16✔
324
            diagnostics = this.getDiagnostics();
6✔
325
        }
326

327
        //group the diagnostics by file
328
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
16✔
329
        for (let diagnostic of diagnostics) {
16✔
330
            const diagnosticFileKey = diagnostic.location?.uri ?? 'no-uri';
7✔
331
            if (!diagnosticsByFile[diagnosticFileKey]) {
7✔
332
                diagnosticsByFile[diagnosticFileKey] = [];
6✔
333
            }
334
            diagnosticsByFile[diagnosticFileKey].push(diagnostic);
7✔
335
        }
336

337
        //get printing options
338
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
339
        const { cwd, emitFullPaths } = options;
16✔
340

341
        let fileUris = Object.keys(diagnosticsByFile).sort();
16✔
342
        for (let fileUri of fileUris) {
16✔
343
            let diagnosticsForFile = diagnosticsByFile[fileUri];
6✔
344
            //sort the diagnostics in line and column order
345
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
346
                return (
1✔
347
                    (a.location?.range?.start.line ?? -1) - (b.location?.range?.start.line ?? -1) ||
20!
348
                    (a.location?.range?.start.character ?? -1) - (b.location?.range?.start.character ?? -1)
18!
349
                );
350
            });
351

352
            let filePath = util.uriToPath(fileUri);
6✔
353
            if (!emitFullPaths) {
6!
354
                filePath = path.relative(cwd, filePath);
6✔
355
            }
356
            //load the file text
357
            const file = this.program?.getFile(fileUri);
6!
358
            //get the file's in-memory contents if available
359
            const lines = (file as BrsFile)?.fileContents?.split(/\r?\n/g) ?? [];
6!
360

361
            for (let diagnostic of sortedDiagnostics) {
6✔
362
                //default the severity to error if undefined
363
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
7✔
364
                let relatedInformation = (util.toDiagnostic(diagnostic, diagnostic.source)?.relatedInformation ?? []).map(x => {
7!
UNCOV
365
                    let relatedInfoFilePath = util.uriToPath(x.location?.uri);
×
UNCOV
366
                    if (!emitFullPaths) {
×
UNCOV
367
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
368
                    }
UNCOV
369
                    return {
×
370
                        filePath: relatedInfoFilePath,
371
                        range: x.location.range,
372
                        message: x.message
373
                    };
374
                });
375
                //format output
376
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
7✔
377
            }
378
        }
379
    }
380

381
    /**
382
     * Run the process once, allowing it to be cancelled.
383
     * NOTE: This should only be called by `runOnce`.
384
     */
385
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; validate: boolean }) {
386
        let wereDiagnosticsPrinted = false;
93✔
387
        try {
93✔
388
            //maybe cancel?
389
            if (options?.cancellationToken?.isCanceled === true) {
93!
UNCOV
390
                return -1;
×
391
            }
392
            //validate program. false means no, everything else (including missing) means true
393
            if (options?.validate !== false) {
93!
394
                this.validateProject();
9✔
395
            }
396

397
            //maybe cancel?
398
            if (options?.cancellationToken?.isCanceled === true) {
93!
UNCOV
399
                return -1;
×
400
            }
401

402
            const diagnostics = this.getDiagnostics();
93✔
403
            this.printDiagnostics(diagnostics);
93✔
404
            wereDiagnosticsPrinted = true;
93✔
405
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
93✔
406

407
            if (errorCount > 0) {
93✔
408
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
409
                return errorCount;
2✔
410
            }
411

412
            //create the deployment package (and transpile as well)
413
            await this.createPackageIfEnabled();
91✔
414

415
            //maybe cancel?
416
            if (options?.cancellationToken?.isCanceled === true) {
91!
UNCOV
417
                return -1;
×
418
            }
419

420
            //deploy the package
421
            await this.deployPackageIfEnabled();
91✔
422

423
            return 0;
91✔
424
        } catch (e) {
UNCOV
425
            if (wereDiagnosticsPrinted === false) {
×
UNCOV
426
                this.printDiagnostics();
×
427
            }
UNCOV
428
            throw e;
×
429
        }
430
    }
431

432
    private async createPackageIfEnabled() {
433
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
91✔
434

435
            //transpile the project
436
            await this.transpile();
6✔
437

438
            //create the zip file if configured to do so
439
            if (this.options.createPackage !== false || this.options.deploy) {
6✔
440
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
441
                    await rokuDeploy.zipPackage({
2✔
442
                        ...this.options,
443
                        logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
444
                        outDir: util.getOutDir(this.options),
445
                        outFile: path.basename(this.options.outFile)
446
                    });
447
                });
448
            }
449
        }
450
    }
451

452
    private buildThrottler = new Throttler(0);
113✔
453

454
    /**
455
     * Build the entire project and place the contents into the staging directory
456
     */
457
    public async build() {
458
        await this.buildThrottler.run(async () => {
6✔
459
            //get every file referenced by the files array
460
            let fileMap = Object.values(this.program.files).map(x => {
6✔
461
                return {
10✔
462
                    src: x.srcPath,
463
                    dest: x.destPath
464
                };
465
            });
466

467
            //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
468
            let filteredFileMap = [] as FileObj[];
6✔
469

470
            for (let fileEntry of fileMap) {
6✔
471
                if (this.program!.hasFile(fileEntry.src) === false) {
10!
UNCOV
472
                    filteredFileMap.push(fileEntry);
×
473
                }
474
            }
475

476
            await this.logger.time(LogLevel.log, ['Building'], async () => {
6✔
477
                //transpile any brighterscript files
478
                await this.program!.build();
6✔
479
            });
480
        });
481
    }
482

483
    /**
484
     * Transpiles the entire program into the staging folder
485
     * @deprecated use `.build()` instead
486
     */
487
    public async transpile() {
488
        return this.build();
6✔
489
    }
490

491
    private async deployPackageIfEnabled() {
492
        //deploy the project if configured to do so
493
        if (this.options.deploy) {
91!
UNCOV
494
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
UNCOV
495
                await rokuDeploy.publish({
×
496
                    ...this.options,
497
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
498
                    outDir: util.getOutDir(this.options),
499
                    outFile: path.basename(this.options.outFile)
500
                });
501
            });
502
        }
503
    }
504

505
    /**
506
     * Load every file into the project
507
     */
508
    private async loadFiles() {
509
        await this.logger.time(LogLevel.log, ['load files'], async () => {
98✔
510
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
98✔
511
                return util.getFilePaths(this.options);
98✔
512
            });
513
            this.logger.trace('ProgramBuilder.loadFiles() files:', files);
98✔
514

515
            const typedefFiles = [] as FileObj[];
98✔
516
            const allOtherFiles = [] as FileObj[];
98✔
517
            let manifestFile: FileObj | null = null;
98✔
518

519
            for (const file of files) {
98✔
520
                // typedef files
521
                if (/\.d\.bs$/i.test(file.dest)) {
88✔
522
                    typedefFiles.push(file);
2✔
523

524
                    // all other files
525
                } else {
526
                    if (/^manifest$/i.test(file.dest)) {
86✔
527
                        //manifest file
528
                        manifestFile = file;
9✔
529
                    }
530
                    allOtherFiles.push(file);
86✔
531
                }
532
            }
533

534
            //load the manifest file first
535
            if (manifestFile) {
98✔
536
                this.program!.loadManifest(manifestFile, false);
9✔
537
            }
538

539
            const loadFile = async (fileObj) => {
98✔
540
                try {
88✔
541
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
88✔
542
                } catch (e) {
UNCOV
543
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
544
                }
545
            };
546
            // preload every type definition file, which eliminates duplicate file loading
547
            await Promise.all(typedefFiles.map(loadFile));
98✔
548
            // load all other files
549
            await Promise.all(allOtherFiles.map(loadFile));
98✔
550
        });
551
    }
552

553
    /**
554
     * Remove all files from the program that are in the specified folder path
555
     * @param srcPath the path to the folder to remove
556
     */
557
    public removeFilesInFolder(srcPath: string): boolean {
NEW
558
        let removedSomeFiles = false;
×
UNCOV
559
        for (let filePath in this.program.files) {
×
560
            //if the file path starts with the parent path and the file path does not exactly match the folder path
UNCOV
561
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
UNCOV
562
                this.program.removeFile(filePath);
×
NEW
563
                removedSomeFiles = true;
×
564
            }
565
        }
NEW
566
        return removedSomeFiles;
×
567
    }
568

569
    /**
570
     * Scan every file and resolve all variable references.
571
     * If no errors were encountered, return true. Otherwise return false.
572
     */
573
    private validateProject() {
574
        this.program.validate();
8✔
575
    }
576

577
    public dispose() {
578
        if (this.watcher) {
106!
UNCOV
579
            this.watcher.dispose();
×
580
        }
581
        if (this.program) {
106!
582
            this.program.dispose?.();
106!
583
        }
584
        if (this.watchInterval) {
106!
UNCOV
585
            clearInterval(this.watchInterval);
×
586
        }
587
    }
588
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc