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

rokucommunity / brighterscript / 26162343514

20 May 2026 12:25PM UTC coverage: 87.289%. First build
26162343514

Pull #1717

github

web-flow
Merge 8cac42d55 into 9de11ed0c
Pull Request #1717: Merges latest v0.72.2 into v1

15912 of 19232 branches covered (82.74%)

Branch coverage included in aggregate %.

240 of 242 new or added lines in 11 files covered. (99.17%)

16584 of 17996 relevant lines covered (92.15%)

27639.26 hits per line

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

70.27
/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();
196✔
31
        this.plugins = new PluginInterface([], { logger: this.logger });
196✔
32

33
        //add the default file resolver (used to load source file contents).
34
        this.addFileResolver((filePath) => {
196✔
35
            return fsExtra.readFile(filePath);
138✔
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;
196✔
42

43
    public options: FinalizedBsConfig = util.normalizeConfig({});
196✔
44
    private isRunning = false;
196✔
45
    private watcher: Watcher | undefined;
46
    public program: Program | undefined;
47
    public logger: Logger;
48
    public plugins: PluginInterface;
49
    private fileResolvers = [] as FileResolver[];
196✔
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);
196✔
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}`;
138✔
66
        let reversedResolvers = [...this.fileResolvers].reverse();
138✔
67
        for (let fileResolver of reversedResolvers) {
138✔
68
            let result = await fileResolver(srcPath);
143✔
69
            if (typeof result === 'string' || Buffer.isBuffer(result)) {
143✔
70
                return result;
138✔
71
            }
72
        }
73
        throw new Error(`Could not load file "${srcPath}"`);
×
74
    }
75

76
    public diagnostics = new DiagnosticManager();
196✔
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();
425✔
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 {
177✔
102
            this.options = util.normalizeAndResolveConfig(options);
177✔
103
            if (this.options?.logLevel !== undefined) {
175!
104
                this.logger.logLevel = this.options?.logLevel;
175!
105
            }
106

107
            if (this.options.noProject) {
175!
108
                this.logger.log(`'noProject' flag is set so bsconfig.json loading is disabled'`);
×
109
            } else if (this.options.project) {
175✔
110
                this.logger.log(`Using config file: "${this.options.project}"`);
75✔
111
            } else {
112
                this.logger.log(`No bsconfig.json file found, using default options`);
100✔
113
            }
114
            this.loadRequires();
175✔
115
            this.loadPlugins();
175✔
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;
177✔
133

134
        this.createProgram();
177✔
135

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

140
    public async run(options: BsConfig) {
141
        if (options?.logLevel) {
177✔
142
            this.logger.logLevel = options.logLevel;
4✔
143
        }
144

145
        if (this.isRunning) {
177!
146
            throw new Error('Server is already running');
×
147
        }
148
        this.isRunning = true;
177✔
149

150
        await this.load(options);
177✔
151

152
        if (this.options.watch) {
177!
153
            this.logger.log('Starting compilation in watch mode...');
×
154
            await this.runOnce({
×
155
                validate: options?.validate,
×
156
                noEmit: options?.noEmit
×
157
            });
158
            this.enableWatchMode();
×
159
        } else {
160
            await this.runOnce({
177✔
161
                validate: options?.validate,
531✔
162
                noEmit: options?.noEmit
531✔
163
            });
164
        }
165
    }
166

167
    protected createProgram() {
168
        this.program = new Program(this.options, this.logger, this.plugins, this.diagnostics);
179✔
169
        this.plugins.emit('provideProgram', {
179✔
170
            builder: this,
171
            program: this.program
172
        });
173

174
        this.plugins.emit('afterProvideProgram', {
179✔
175
            builder: this,
176
            program: this.program
177
        });
178

179
        return this.program;
179✔
180
    }
181

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

194
        this.plugins.emit('beforeProvideProgram', {
175✔
195
            builder: this
196
        });
197
    }
198

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

208
    private clearConsole() {
209
        if (this.allowConsoleClearing) {
174!
210
            util.clearConsole();
174✔
211
        }
212
    }
213

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

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

228
        //clear the console
229
        this.clearConsole();
×
230

231
        let fileObjects = rokuDeploy.normalizeFilesArray(this.options.files ? this.options.files : []);
×
232

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

239
        this.logger.log('Watching for file changes...');
×
240

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

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

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

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

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

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

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

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

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

337
        //get printing options
338
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
20✔
339
        const { cwd, emitFullPaths } = options;
20✔
340
        //custom-template reporters are pre-resolved once so we don't recompile them per diagnostic;
341
        //the resolved function is stashed on the entry as `run` so we don't have to keep a parallel array.
342
        //invalid entries are warned about and skipped (we never want to abort a build over a config typo).
343
        const reporters = diagnosticUtils.normalizeDiagnosticReporters(
20✔
344
            this.options?.diagnosticReporters,
60!
345
            this.logger
346
        )
347
            .map(reporter => (reporter.type === 'custom'
24✔
348
                ? { ...reporter, run: diagnosticUtils.createCustomDiagnosticReporter(reporter.format) }
24✔
349
                : reporter));
350
        if (reporters.length === 0) {
20!
NEW
351
            return;
×
352
        }
353

354
        let fileUris = Object.keys(diagnosticsByFile).sort();
20✔
355
        for (let fileUri of fileUris) {
20✔
356
            let diagnosticsForFile = diagnosticsByFile[fileUri];
8✔
357
            //sort the diagnostics in line and column order
358
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
8✔
359
                return (
1✔
360
                    (a.location?.range?.start.line ?? -1) - (b.location?.range?.start.line ?? -1) ||
20!
361
                    (a.location?.range?.start.character ?? -1) - (b.location?.range?.start.character ?? -1)
18!
362
                );
363
            });
364

365
            let filePath = util.uriToPath(fileUri);
8✔
366
            if (!emitFullPaths) {
8!
367
                filePath = path.relative(cwd, filePath);
8✔
368
            }
369
            //load the file text
370
            const file = this.program?.getFile(fileUri);
8!
371
            //get the file's in-memory contents if available
372
            const lines = (file as BrsFile)?.fileContents?.split(/\r?\n/g) ?? [];
8!
373

374
            for (let diagnostic of sortedDiagnostics) {
8✔
375
                //default the severity to error if undefined
376
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
9✔
377
                let relatedInformation = (util.toDiagnostic(diagnostic, diagnostic.source)?.relatedInformation ?? []).map(x => {
9!
378
                    let relatedInfoFilePath = util.uriToPath(x.location?.uri);
×
379
                    if (!emitFullPaths) {
×
380
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
381
                    }
382
                    return {
×
383
                        filePath: relatedInfoFilePath,
384
                        range: x.location.range,
385
                        message: x.message
386
                    };
387
                });
388
                //format output once per configured reporter
389
                for (const reporter of reporters) {
9✔
390
                    if (reporter.type === 'github-actions') {
13✔
391
                        diagnosticUtils.printDiagnosticGithubActions({ options: options, severity: severity, filePath: filePath, diagnostic: diagnostic });
2✔
392
                    } else if (reporter.type === 'custom') {
11✔
393
                        reporter.run({ options: options, severity: severity, filePath: filePath, diagnostic: diagnostic });
2✔
394
                    } else {
395
                        diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
9✔
396
                    }
397
                }
398
            }
399
        }
400
    }
401

402
    /**
403
     * Run the process once, allowing it to be cancelled.
404
     * NOTE: This should only be called by `runOnce`.
405
     */
406
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; validate: boolean; noEmit: boolean }) {
407
        let wereDiagnosticsPrinted = false;
174✔
408
        try {
174✔
409
            //maybe cancel?
410
            if (options?.cancellationToken?.isCanceled === true) {
174!
411
                return -1;
×
412
            }
413
            //the prop-drilled validate value takes precedence over this.options.validate.
414
            //false means no, everything else (including missing) means true
415
            if ((options?.validate ?? this.options.validate) !== false) {
174!
416
                this.validateProject();
9✔
417
            }
418

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

424
            const diagnostics = this.getDiagnostics();
174✔
425
            this.printDiagnostics(diagnostics);
174✔
426
            wereDiagnosticsPrinted = true;
174✔
427
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
174✔
428

429
            if (errorCount > 0) {
174✔
430
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
431
                return errorCount;
2✔
432
            }
433

434
            if (options.noEmit !== true) {
172✔
435
                await this.transpile();
9✔
436
            }
437

438
            //maybe cancel?
439
            if (options?.cancellationToken?.isCanceled === true) {
172!
440
                return -1;
×
441
            }
442

443
            return 0;
172✔
444
        } catch (e) {
445
            if (wereDiagnosticsPrinted === false) {
×
446
                this.printDiagnostics();
×
447
            }
448
            throw e;
×
449
        }
450
    }
451

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

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

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

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

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

490
    /**
491
     * Load every file into the project
492
     */
493
    private async loadFiles() {
494
        await this.logger.time(LogLevel.log, ['load files'], async () => {
179✔
495
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
179✔
496
                return util.getFilePaths(this.options);
179✔
497
            });
498
            this.logger.trace('ProgramBuilder.loadFiles() files:', files);
179✔
499

500
            const typedefFiles = [] as FileObj[];
179✔
501
            const allOtherFiles = [] as FileObj[];
179✔
502
            let manifestFile: FileObj | null = null;
179✔
503

504
            for (const file of files) {
179✔
505
                // typedef files
506
                if (/\.d\.bs$/i.test(file.dest)) {
145✔
507
                    typedefFiles.push(file);
2✔
508

509
                    // all other files
510
                } else {
511
                    if (/^manifest$/i.test(file.dest)) {
143✔
512
                        //manifest file
513
                        manifestFile = file;
29✔
514
                    }
515
                    allOtherFiles.push(file);
143✔
516
                }
517
            }
518

519
            //load the manifest file first
520
            if (manifestFile) {
179✔
521
                this.program!.loadManifest(manifestFile, false);
29✔
522
            }
523

524
            const loadFile = async (fileObj) => {
179✔
525
                try {
145✔
526
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
145✔
527
                } catch (e) {
528
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
529
                }
530
            };
531
            // preload every type definition file, which eliminates duplicate file loading
532
            await Promise.all(typedefFiles.map(loadFile));
179✔
533
            // load all other files
534
            await Promise.all(allOtherFiles.map(loadFile));
179✔
535
        });
536
    }
537

538
    /**
539
     * Remove all files from the program that are in the specified folder path
540
     * @param srcPath the path to the folder to remove
541
     */
542
    public removeFilesInFolder(srcPath: string): boolean {
543
        let removedSomeFiles = false;
×
544
        for (let filePath in this.program.files) {
×
545
            //if the file path starts with the parent path and the file path does not exactly match the folder path
546
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
547
                this.program.removeFile(filePath);
×
548
                removedSomeFiles = true;
×
549
            }
550
        }
551
        return removedSomeFiles;
×
552
    }
553

554
    /**
555
     * Scan every file and resolve all variable references.
556
     * If no errors were encountered, return true. Otherwise return false.
557
     */
558
    private validateProject() {
559
        this.program.validate();
8✔
560
    }
561

562
    public dispose() {
563
        if (this.watcher) {
189!
564
            this.watcher.dispose();
×
565
        }
566
        if (this.program) {
189!
567
            this.program.dispose?.();
189!
568
        }
569
        if (this.watchInterval) {
189!
570
            clearInterval(this.watchInterval);
×
571
        }
572
    }
573
}
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