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

rokucommunity / brighterscript / #15503

28 Mar 2026 09:13PM UTC coverage: 88.376% (-0.7%) from 89.035%
#15503

push

web-flow
Merge e74d0b0da into f3673e7df

8226 of 9814 branches covered (83.82%)

Branch coverage included in aggregate %.

103 of 184 new or added lines in 5 files covered. (55.98%)

7 existing lines in 3 files now uncovered.

10408 of 11271 relevant lines covered (92.34%)

1967.5 hits per line

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

71.34
/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 { BscFile, 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 { URI } from 'vscode-uri';
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).then((value) => {
70✔
36
                return value.toString();
70✔
37
            });
38
        });
39
    }
40
    /**
41
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
42
     */
43
    public allowConsoleClearing = true;
142✔
44

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

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

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

78
    /**
79
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
80
     */
81
    private staticDiagnostics = [] as BsDiagnostic[];
142✔
82

83
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
84
        if (!this.program) {
1!
85
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
86
        }
87
        let file: BscFile | undefined = this.program.getFile(srcPath);
1✔
88
        if (!file) {
1!
89
            file = {
1✔
90
                pkgPath: path.basename(srcPath),
91
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
92
                srcPath: srcPath,
93
                getDiagnostics: () => {
94
                    return [<any>diagnostic];
×
95
                }
96
            } as BscFile;
97
        }
98
        diagnostic.file = file;
1✔
99
        this.staticDiagnostics.push(<any>diagnostic);
1✔
100
    }
101

102
    public getDiagnostics() {
103
        return [
287✔
104
            ...this.staticDiagnostics,
105
            ...(this.program?.getDiagnostics() ?? [])
1,722!
106
        ];
107
    }
108

109
    public async run(options: BsConfig & {
110
        /**
111
         * Should validation run? Default is `true`. You must set explicitly to `false` to disable.
112
         * @deprecated this is an experimental flag, and its behavior may change in a future release
113
         * @default true
114
         */
115
        validate?: boolean;
116
    }) {
117
        if (options?.logLevel) {
127✔
118
            this.logger.logLevel = options.logLevel;
2✔
119
        }
120

121
        if (this.isRunning) {
127!
122
            throw new Error('Server is already running');
×
123
        }
124
        this.isRunning = true;
127✔
125
        try {
127✔
126
            this.options = util.normalizeAndResolveConfig(options);
127✔
127
            if (this.options?.logLevel !== undefined) {
125!
128
                this.logger.logLevel = this.options?.logLevel;
125!
129
            }
130

131
            if (this.options.noProject) {
125!
132
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
133
            } else if (this.options.project) {
125✔
134
                this.logger.log(`Using config file: "${this.options.project}"`);
53✔
135
            } else {
136
                this.logger.log(`No bsconfig.json file found, using default options`);
72✔
137
            }
138
            this.loadRequires();
125✔
139
            this.loadPlugins();
125✔
140
        } catch (e: any) {
141
            //For now, just use a default options object so we have a functioning program
142
            this.options = util.normalizeConfig({
2✔
143
                showDiagnosticsInConsole: options?.showDiagnosticsInConsole
6!
144
            });
145

146
            if (e?.file && e.message && e.code) {
2!
147
                let err = e as BsDiagnostic;
2✔
148
                this.staticDiagnostics.push(err);
2✔
149
            } else {
150
                //if this is not a diagnostic, something else is wrong...
151
                throw e;
×
152
            }
153

154
            this.printDiagnostics();
2✔
155
        }
156
        this.logger.logLevel = this.options.logLevel;
127✔
157

158
        this.createProgram();
127✔
159

160
        //parse every file in the entire project
161
        await this.loadAllFilesAST();
127✔
162

163
        if (this.options.watch) {
127!
164
            this.logger.log('Starting compilation in watch mode...');
×
165
            await this.runOnce({
×
166
                validate: options?.validate
×
167
            });
168
            this.enableWatchMode();
×
169
        } else {
170
            await this.runOnce({
127✔
171
                validate: options?.validate
381✔
172
            });
173
        }
174
    }
175

176
    protected createProgram() {
177
        this.program = new Program(this.options, this.logger, this.plugins);
128✔
178

179
        this.plugins.emit('afterProgramCreate', this.program);
128✔
180

181
        return this.program;
128✔
182
    }
183

184
    protected loadPlugins() {
185
        const cwd = this.options.cwd ?? process.cwd();
125!
186
        const plugins = util.loadPlugins(
125✔
187
            cwd,
188
            this.options.plugins ?? [],
375!
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);
125!
192
        for (let plugin of plugins) {
125✔
193
            this.plugins.add(plugin);
1✔
194
        }
195

196
        this.plugins.emit('beforeProgramCreate', this);
125✔
197
    }
198

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

208
    private clearConsole() {
209
        if (this.allowConsoleClearing) {
124!
210
            util.clearConsole();
124✔
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
            //ignore file events for synthetic files (e.g. extracted CDATA scripts) — they are managed
254
            //programmatically by XmlFile and don't exist on disk in the source directory
NEW
255
            if (this.program.getFile(thePath)?.isSynthetic) {
×
NEW
256
                return;
×
257
            }
258
            if (event === 'add' || event === 'change') {
×
259
                const fileObj = {
×
260
                    src: thePath,
261
                    dest: rokuDeploy.getDestPath(
262
                        thePath,
263
                        this.program.options.files,
264
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
265
                        util.driveLetterToLower(this.rootDir)
266
                    )
267
                };
268
                this.program.setFile(
×
269
                    fileObj,
270
                    await this.getFileContents(fileObj.src)
271
                );
272
            } else if (event === 'unlink') {
×
273
                this.program.removeFile(thePath);
×
274
            }
275
            //wait for change events to settle, and then execute `run`
276
            await debouncedRunOnce();
×
277
        });
278
    }
279

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

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

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

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

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

330
        //group the diagnostics by file
331
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
14✔
332
        for (let diagnostic of diagnostics) {
14✔
333
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
7✔
334
                diagnosticsByFile[diagnostic.file.srcPath] = [];
6✔
335
            }
336
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
7✔
337
        }
338

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

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

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

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

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

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

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

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

414
            //create the deployment package (and transpile as well)
415
            await this.createPackageIfEnabled();
122✔
416

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

422
            //deploy the package
423
            await this.deployPackageIfEnabled();
122✔
424

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

434
    private async createPackageIfEnabled() {
435
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
122✔
436

437
            //transpile the project
438
            await this.transpile();
4✔
439

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

454
    private transpileThrottler = new Throttler(0);
142✔
455
    /**
456
     * Transpiles the entire program into the staging folder
457
     */
458
    public async transpile() {
459
        await this.transpileThrottler.run(async () => {
4✔
460
            let options = util.cwdWork(this.options.cwd, () => {
4✔
461
                return rokuDeploy.getOptions({
4✔
462
                    ...this.options,
463
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
464
                    outDir: util.getOutDir(this.options),
465
                    outFile: path.basename(this.options.outFile)
466

467
                    //rokuDeploy's return type says all its fields can be nullable, but it sets values for all of them.
468
                }) as any as Required<ReturnType<typeof rokuDeploy.getOptions>>;
469
            });
470

471
            //get every file referenced by the files array
472
            let fileMap = await rokuDeploy.getFilePaths(options.files, options.rootDir);
4✔
473

474
            //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
475
            let filteredFileMap = [] as FileObj[];
4✔
476

477
            for (let fileEntry of fileMap) {
4✔
478
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
479
                    filteredFileMap.push(fileEntry);
2✔
480
                }
481
            }
482

483
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
484

485
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
486
                //prepublish all non-program-loaded files to staging
487
                await rokuDeploy.prepublishToStaging({
4✔
488
                    ...options,
489
                    files: filteredFileMap
490
                });
491
            });
492

493
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
494
            this.plugins.emit('beforePublish', this, fileMap);
4✔
495

496
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
497
                //transpile any brighterscript files
498
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
499
            });
500

501
            this.plugins.emit('afterPublish', this, fileMap);
4✔
502
        });
503
    }
504

505
    private async deployPackageIfEnabled() {
506
        //deploy the project if configured to do so
507
        if (this.options.deploy) {
122!
508
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
509
                await rokuDeploy.publish({
×
510
                    ...this.options,
511
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
512
                    outDir: util.getOutDir(this.options),
513
                    outFile: path.basename(this.options.outFile)
514
                });
515
            });
516
        }
517
    }
518

519
    /**
520
     * Parse and load the AST for every file in the project
521
     */
522
    private async loadAllFilesAST() {
523
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
129✔
524
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
129✔
525
                return util.getFilePaths(this.options);
129✔
526
            });
527
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
129✔
528

529
            const typedefFiles = [] as FileObj[];
129✔
530
            const sourceFiles = [] as FileObj[];
129✔
531
            let manifestFile: FileObj | null = null;
129✔
532

533
            for (const file of files) {
129✔
534
                // source files (.brs, .bs, .xml)
535
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
90✔
536
                    sourceFiles.push(file);
74✔
537

538
                    // typedef files (.d.bs)
539
                } else if (/\.d\.bs$/i.test(file.dest)) {
16✔
540
                    typedefFiles.push(file);
2✔
541

542
                    // manifest file
543
                } else if (/^manifest$/i.test(file.dest)) {
14✔
544
                    manifestFile = file;
13✔
545
                }
546
            }
547

548
            if (manifestFile) {
129✔
549
                this.program!.loadManifest(manifestFile, false);
13✔
550
            }
551

552
            const loadFile = async (fileObj) => {
129✔
553
                try {
76✔
554
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
76✔
555
                } catch (e) {
556
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
557
                }
558
            };
559
            await Promise.all(typedefFiles.map(loadFile)); // preload every type definition file, which eliminates duplicate file loading
129✔
560
            await Promise.all(sourceFiles.map(loadFile)); // parse source files
129✔
561
        });
562
    }
563

564
    /**
565
     * Remove all files from the program that are in the specified folder path
566
     * @param srcPath the path to the folder to remove
567
     */
568
    public removeFilesInFolder(srcPath: string): boolean {
569
        let removedSomeFiles = false;
×
570
        for (let filePath in this.program.files) {
×
571
            //if the file path starts with the parent path and the file path does not exactly match the folder path
572
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
573
                this.program.removeFile(filePath);
×
574
                removedSomeFiles = true;
×
575
            }
576
        }
577
        return removedSomeFiles;
×
578
    }
579

580
    /**
581
     * Scan every file and resolve all variable references.
582
     * If no errors were encountered, return true. Otherwise return false.
583
     */
584
    private validateProject() {
585
        this.program.validate();
6✔
586
    }
587

588
    public dispose() {
589
        if (this.watcher) {
136!
590
            this.watcher.dispose();
×
591
        }
592
        if (this.program) {
136!
593
            this.program.dispose?.();
136!
594
        }
595
        if (this.watchInterval) {
136!
596
            clearInterval(this.watchInterval);
×
597
        }
598
    }
599
}
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