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

rokucommunity / brighterscript / #15065

08 Jan 2026 07:11PM UTC coverage: 87.296%. Remained the same
#15065

push

web-flow
Merge 91b96a2e0 into 2ea4d2108

14409 of 17441 branches covered (82.62%)

Branch coverage included in aggregate %.

57 of 57 new or added lines in 12 files covered. (100.0%)

90 existing lines in 4 files now uncovered.

15103 of 16366 relevant lines covered (92.28%)

24217.7 hits per line

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

69.38
/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 { BsConfig, FinalizedBsConfig } from './BsConfig';
5
import type { BsDiagnostic, FileObj, FileResolver } from './interfaces';
6
import { Program } from './Program';
1✔
7
import { standardizePath as s, util } from './util';
1✔
8
import { Watcher } from './Watcher';
1✔
9
import { DiagnosticSeverity } from 'vscode-languageserver';
1✔
10
import type { Logger } from './logging';
11
import { LogLevel, createLogger } from './logging';
1✔
12
import PluginInterface from './PluginInterface';
1✔
13
import * as diagnosticUtils from './diagnosticUtils';
1✔
14
import * as fsExtra from 'fs-extra';
1✔
15
import * as requireRelative from 'require-relative';
1✔
16
import { Throttler } from './Throttler';
1✔
17
import type { BrsFile } from './files/BrsFile';
18
import { DiagnosticManager } from './DiagnosticManager';
1✔
19

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

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

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

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

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

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

76
    public diagnostics = new DiagnosticManager();
142✔
77

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

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

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

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

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

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

134
        this.createProgram();
125✔
135

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

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

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

157
        await this.load(options);
125✔
158

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

174
    protected createProgram() {
175
        this.program = new Program(this.options, this.logger, this.plugins, this.diagnostics);
127✔
176
        this.plugins.emit('provideProgram', {
127✔
177
            builder: this,
178
            program: this.program
179
        });
180

181
        this.plugins.emit('afterProvideProgram', {
127✔
182
            builder: this,
183
            program: this.program
184
        });
185

186
        return this.program;
127✔
187
    }
188

189
    protected loadPlugins() {
190
        const cwd = this.options.cwd ?? process.cwd();
123✔
191
        const plugins = util.loadPlugins(
123✔
192
            cwd,
193
            this.options.plugins ?? [],
369!
UNCOV
194
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
195
        );
196
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins);
123!
197
        for (let plugin of plugins) {
123✔
198
            this.plugins.add(plugin);
1✔
199
        }
200

201
        this.plugins.emit('beforeProvideProgram', {
123✔
202
            builder: this
203
        });
204
    }
205

206
    /**
207
     * `require()` every options.require path
208
     */
209
    protected loadRequires() {
210
        for (const dep of this.options.require ?? []) {
123✔
211
            requireRelative(dep, this.options.cwd);
2✔
212
        }
213
    }
214

215
    private clearConsole() {
216
        if (this.allowConsoleClearing) {
122!
217
            util.clearConsole();
122✔
218
        }
219
    }
220

221
    /**
222
     * A handle for the watch mode interval that keeps the process alive.
223
     * We need this so we can clear it if the builder is disposed
224
     */
225
    private watchInterval: NodeJS.Timer | undefined;
226

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

235
        //clear the console
UNCOV
236
        this.clearConsole();
×
237

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

240
        //add each set of files to the file watcher
UNCOV
241
        for (let fileObject of fileObjects) {
×
242
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
UNCOV
243
            this.watcher.watch(src);
×
244
        }
245

246
        this.logger.log('Watching for file changes...');
×
247

UNCOV
248
        let debouncedRunOnce = debounce(async () => {
×
UNCOV
249
            this.logger.log('File change detected. Starting incremental compilation...');
×
250
            await this.runOnce();
×
UNCOV
251
            this.logger.log(`Watching for file changes.`);
×
252
        }, 50);
253

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

282
    /**
283
     * The rootDir for this program.
284
     */
285
    public get rootDir() {
UNCOV
286
        if (!this.program) {
×
UNCOV
287
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
288
        }
UNCOV
289
        return this.program.options.rootDir;
×
290
    }
291

292
    /**
293
     * A method that is used to cancel a previous run task.
294
     * Does nothing if previous run has completed or was already canceled
295
     */
296
    private cancelLastRun = () => {
142✔
297
        return Promise.resolve();
122✔
298
    };
299

300
    /**
301
     * Run the entire process exactly one time.
302
     */
303
    private runOnce(options?: { validate?: boolean; noEmit?: boolean }) {
304

305
        //clear the console
306
        this.clearConsole();
122✔
307
        let cancellationToken = { isCanceled: false };
122✔
308
        //wait for the previous run to complete
309
        let runPromise = this.cancelLastRun().then(() => {
122✔
310
            //start the new run
311
            return this._runOnce({
122✔
312
                cancellationToken: cancellationToken,
313
                validate: options?.validate,
366!
314
                noEmit: options?.noEmit
366!
315
            });
316
        }) as any;
317

318
        //a function used to cancel this run
319
        this.cancelLastRun = () => {
122✔
UNCOV
320
            cancellationToken.isCanceled = true;
×
UNCOV
321
            return runPromise;
×
322
        };
323
        return runPromise;
122✔
324
    }
325

326
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
327
        if (this.options?.showDiagnosticsInConsole === false) {
130!
328
            return;
114✔
329
        }
330
        if (!diagnostics) {
16✔
331
            diagnostics = this.getDiagnostics();
6✔
332
        }
333

334
        //group the diagnostics by file
335
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
16✔
336
        for (let diagnostic of diagnostics) {
16✔
337
            const diagnosticFileKey = diagnostic.location?.uri ?? 'no-uri';
7✔
338
            if (!diagnosticsByFile[diagnosticFileKey]) {
7✔
339
                diagnosticsByFile[diagnosticFileKey] = [];
6✔
340
            }
341
            diagnosticsByFile[diagnosticFileKey].push(diagnostic);
7✔
342
        }
343

344
        //get printing options
345
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
346
        const { cwd, emitFullPaths } = options;
16✔
347

348
        let fileUris = Object.keys(diagnosticsByFile).sort();
16✔
349
        for (let fileUri of fileUris) {
16✔
350
            let diagnosticsForFile = diagnosticsByFile[fileUri];
6✔
351
            //sort the diagnostics in line and column order
352
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
353
                return (
1✔
354
                    (a.location?.range?.start.line ?? -1) - (b.location?.range?.start.line ?? -1) ||
20!
355
                    (a.location?.range?.start.character ?? -1) - (b.location?.range?.start.character ?? -1)
18!
356
                );
357
            });
358

359
            let filePath = util.uriToPath(fileUri);
6✔
360
            if (!emitFullPaths) {
6!
361
                filePath = path.relative(cwd, filePath);
6✔
362
            }
363
            //load the file text
364
            const file = this.program?.getFile(fileUri);
6!
365
            //get the file's in-memory contents if available
366
            const lines = (file as BrsFile)?.fileContents?.split(/\r?\n/g) ?? [];
6!
367

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

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

404
            //maybe cancel?
405
            if (options?.cancellationToken?.isCanceled === true) {
122!
UNCOV
406
                return -1;
×
407
            }
408

409
            const diagnostics = this.getDiagnostics();
122✔
410
            this.printDiagnostics(diagnostics);
122✔
411
            wereDiagnosticsPrinted = true;
122✔
412
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
122✔
413

414
            if (errorCount > 0) {
122✔
415
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
416
                return errorCount;
2✔
417
            }
418

419
            if (options.noEmit !== true) {
120✔
420
                await this.transpile();
6✔
421
            }
422

423
            //maybe cancel?
424
            if (options?.cancellationToken?.isCanceled === true) {
120!
UNCOV
425
                return -1;
×
426
            }
427

428
            return 0;
120✔
429
        } catch (e) {
UNCOV
430
            if (wereDiagnosticsPrinted === false) {
×
UNCOV
431
                this.printDiagnostics();
×
432
            }
UNCOV
433
            throw e;
×
434
        }
435
    }
436

437
    private buildThrottler = new Throttler(0);
142✔
438
    /**
439
     * Build the entire project and place the contents into the staging directory
440
     */
441
    public async build() {
442
        await this.buildThrottler.run(async () => {
6✔
443
            //get every file referenced by the files array
444
            let fileMap = Object.values(this.program.files).map(x => {
6✔
445
                return {
11✔
446
                    src: x.srcPath,
447
                    dest: x.destPath
448
                };
449
            });
450

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

454
            for (let fileEntry of fileMap) {
6✔
455
                if (this.program!.hasFile(fileEntry.src) === false) {
11!
UNCOV
456
                    filteredFileMap.push(fileEntry);
×
457
                }
458
            }
459

460
            await this.logger.time(LogLevel.log, ['Building'], async () => {
6✔
461
                //transpile any brighterscript files
462
                await this.program!.build();
6✔
463
            });
464
        });
465
    }
466

467
    /**
468
     * Transpiles the entire program into the staging folder
469
     * @deprecated use `.build()` instead
470
     */
471
    public async transpile() {
472
        return this.build();
6✔
473
    }
474

475
    /**
476
     * Load every file into the project
477
     */
478
    private async loadFiles() {
479
        await this.logger.time(LogLevel.log, ['load files'], async () => {
127✔
480
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
127✔
481
                return util.getFilePaths(this.options);
127✔
482
            });
483
            this.logger.trace('ProgramBuilder.loadFiles() files:', files);
127✔
484

485
            const typedefFiles = [] as FileObj[];
127✔
486
            const allOtherFiles = [] as FileObj[];
127✔
487
            let manifestFile: FileObj | null = null;
127✔
488

489
            for (const file of files) {
127✔
490
                // typedef files
491
                if (/\.d\.bs$/i.test(file.dest)) {
106✔
492
                    typedefFiles.push(file);
2✔
493

494
                    // all other files
495
                } else {
496
                    if (/^manifest$/i.test(file.dest)) {
104✔
497
                        //manifest file
498
                        manifestFile = file;
20✔
499
                    }
500
                    allOtherFiles.push(file);
104✔
501
                }
502
            }
503

504
            //load the manifest file first
505
            if (manifestFile) {
127✔
506
                this.program!.loadManifest(manifestFile, false);
20✔
507
            }
508

509
            const loadFile = async (fileObj) => {
127✔
510
                try {
106✔
511
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
106✔
512
                } catch (e) {
UNCOV
513
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
514
                }
515
            };
516
            // preload every type definition file, which eliminates duplicate file loading
517
            await Promise.all(typedefFiles.map(loadFile));
127✔
518
            // load all other files
519
            await Promise.all(allOtherFiles.map(loadFile));
127✔
520
        });
521
    }
522

523
    /**
524
     * Remove all files from the program that are in the specified folder path
525
     * @param srcPath the path to the folder to remove
526
     */
527
    public removeFilesInFolder(srcPath: string): boolean {
UNCOV
528
        let removedSomeFiles = false;
×
UNCOV
529
        for (let filePath in this.program.files) {
×
530
            //if the file path starts with the parent path and the file path does not exactly match the folder path
UNCOV
531
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
532
                this.program.removeFile(filePath);
×
533
                removedSomeFiles = true;
×
534
            }
535
        }
536
        return removedSomeFiles;
×
537
    }
538

539
    /**
540
     * Scan every file and resolve all variable references.
541
     * If no errors were encountered, return true. Otherwise return false.
542
     */
543
    private validateProject() {
544
        this.program.validate();
8✔
545
    }
546

547
    public dispose() {
548
        if (this.watcher) {
135!
UNCOV
549
            this.watcher.dispose();
×
550
        }
551
        if (this.program) {
135!
552
            this.program.dispose?.();
135!
553
        }
554
        if (this.watchInterval) {
135!
UNCOV
555
            clearInterval(this.watchInterval);
×
556
        }
557
    }
558
}
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