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

rokucommunity / brighterscript / #15048

01 Jan 2026 11:17PM UTC coverage: 87.048% (-0.9%) from 87.907%
#15048

push

web-flow
Merge 02ba2bb57 into 2ea4d2108

14498 of 17595 branches covered (82.4%)

Branch coverage included in aggregate %.

192 of 261 new or added lines in 12 files covered. (73.56%)

897 existing lines in 48 files now uncovered.

15248 of 16577 relevant lines covered (91.98%)

24112.76 hits per line

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

69.32
/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
        }
UNCOV
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>) {
UNCOV
79
        if (!this.program) {
×
UNCOV
80
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
81
        }
82
        if (!diagnostic.location) {
×
UNCOV
83
            diagnostic.location = {
×
84
                uri: util.pathToUri(srcPath),
85
                range: util.createRange(0, 0, 0, 0)
86
            };
87
        } else {
UNCOV
88
            diagnostic.location.uri = util.pathToUri(srcPath);
×
89
        }
UNCOV
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!
UNCOV
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...
UNCOV
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!
UNCOV
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!
UNCOV
160
            this.logger.log('Starting compilation in watch mode...');
×
UNCOV
161
            await this.runOnce({
×
162
                validate: options?.validate,
×
163
                noEmit: options?.noEmit
×
164
            });
UNCOV
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

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

182
        return this.program;
127✔
183
    }
184

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

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

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

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

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

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

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

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

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

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

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

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

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

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

296
    /**
297
     * Run the entire process exactly one time.
298
     */
299
    private runOnce(options?: { validate?: boolean; noEmit?: boolean }) {
300

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

314
        //a function used to cancel this run
315
        this.cancelLastRun = () => {
122✔
UNCOV
316
            cancellationToken.isCanceled = true;
×
UNCOV
317
            return runPromise;
×
318
        };
319
        return runPromise;
122✔
320
    }
321

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

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

340
        //get printing options
341
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
342
        const { cwd, emitFullPaths } = options;
16✔
343

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

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

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

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

400
            //maybe cancel?
401
            if (options?.cancellationToken?.isCanceled === true) {
122!
UNCOV
402
                return -1;
×
403
            }
404

405
            const diagnostics = this.getDiagnostics();
122✔
406
            this.printDiagnostics(diagnostics);
122✔
407
            wereDiagnosticsPrinted = true;
122✔
408
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
122✔
409

410
            if (errorCount > 0) {
122✔
411
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
412
                return errorCount;
2✔
413
            }
414

415
            if (options.noEmit !== true) {
120✔
416
                await this.transpile();
6✔
417
            }
418

419
            //maybe cancel?
420
            if (options?.cancellationToken?.isCanceled === true) {
120!
UNCOV
421
                return -1;
×
422
            }
423

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

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

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

450
            for (let fileEntry of fileMap) {
6✔
451
                if (this.program!.hasFile(fileEntry.src) === false) {
11!
UNCOV
452
                    filteredFileMap.push(fileEntry);
×
453
                }
454
            }
455

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

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

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

481
            const typedefFiles = [] as FileObj[];
127✔
482
            const allOtherFiles = [] as FileObj[];
127✔
483
            let manifestFile: FileObj | null = null;
127✔
484

485
            for (const file of files) {
127✔
486
                // typedef files
487
                if (/\.d\.bs$/i.test(file.dest)) {
106✔
488
                    typedefFiles.push(file);
2✔
489

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

500
            //load the manifest file first
501
            if (manifestFile) {
127✔
502
                this.program!.loadManifest(manifestFile, false);
20✔
503
            }
504

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

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

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

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