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

rokucommunity / brighterscript / #13591

13 Jan 2025 02:40PM UTC coverage: 86.911%. Remained the same
#13591

push

web-flow
Merge 38702985d into 9d6ef67ba

12071 of 14663 branches covered (82.32%)

Branch coverage included in aggregate %.

90 of 94 new or added lines in 11 files covered. (95.74%)

93 existing lines in 8 files now uncovered.

13048 of 14239 relevant lines covered (91.64%)

31887.09 hits per line

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

70.09
/src/ProgramBuilder.ts
1
import * as debounce from 'debounce-promise';
1✔
2
import * as path from 'path';
1✔
3
import { rokuDeploy } from 'roku-deploy';
1✔
4
import type { LogLevel as RokuDeployLogLevel } from 'roku-deploy/dist/Logger';
5
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
6
import type { BsDiagnostic, FileObj, FileResolver } from './interfaces';
7
import { Program } from './Program';
1✔
8
import { standardizePath as s, util } from './util';
1✔
9
import { Watcher } from './Watcher';
1✔
10
import { DiagnosticSeverity } from 'vscode-languageserver';
1✔
11
import { 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
        //add the default file resolver (used to load source file contents).
27
        this.addFileResolver((filePath) => {
68✔
28
            return fsExtra.readFile(filePath);
33✔
29
        });
30
    }
31
    /**
32
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
33
     */
34
    public allowConsoleClearing = true;
68✔
35

36
    public options: FinalizedBsConfig = util.normalizeConfig({});
68✔
37
    private isRunning = false;
68✔
38
    private watcher: Watcher | undefined;
39
    public program: Program | undefined;
40
    public logger = createLogger();
68✔
41
    public plugins: PluginInterface = new PluginInterface([], { logger: this.logger });
68✔
42
    private fileResolvers = [] as FileResolver[];
68✔
43

44
    public addFileResolver(fileResolver: FileResolver) {
45
        this.fileResolvers.push(fileResolver);
106✔
46
    }
47

48
    /**
49
     * Get the contents of the specified file as a string.
50
     * This walks backwards through the file resolvers until we get a value.
51
     * This allow the language server to provide file contents directly from memory.
52
     */
53
    public async getFileContents(srcPath: string) {
54
        srcPath = s`${srcPath}`;
33✔
55
        let reversedResolvers = [...this.fileResolvers].reverse();
33✔
56
        for (let fileResolver of reversedResolvers) {
33✔
57
            let result = await fileResolver(srcPath);
55✔
58
            if (typeof result === 'string' || Buffer.isBuffer(result)) {
55✔
59
                return result;
33✔
60
            }
61
        }
62
        throw new Error(`Could not load file "${srcPath}"`);
×
63
    }
64

65
    public diagnostics = new DiagnosticManager();
68✔
66

67
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
68
        if (!this.program) {
×
69
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
70
        }
71
        if (!diagnostic.location) {
×
72
            diagnostic.location = {
×
73
                uri: util.pathToUri(srcPath),
74
                range: util.createRange(0, 0, 0, 0)
75
            };
76
        } else {
77
            diagnostic.location.uri = util.pathToUri(srcPath);
×
78
        }
79
        this.diagnostics.register(<any>diagnostic, { tags: ['ProgramBuilder'] });
×
80
    }
81

82
    public getDiagnostics() {
83
        return this.diagnostics.getDiagnostics();
127✔
84
    }
85

86
    /**
87
     * Load the project and all the files, but don't run the validation, transpile, or watch cycles
88
     */
89
    public async load(options: BsConfig) {
90
        try {
50✔
91
            this.options = util.normalizeAndResolveConfig(options);
50✔
92
            if (this.options.noProject) {
49!
93
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
94
            } else if (this.options.project) {
49✔
95
                this.logger.log(`Using config file: "${this.options.project}"`);
9✔
96
            } else {
97
                this.logger.log(`No bsconfig.json file found, using default options`);
40✔
98
            }
99
            this.loadRequires();
49✔
100
            this.loadPlugins();
49✔
101
        } catch (e: any) {
102
            if (e?.location && e.message && e.code) {
1!
103
                let err = e as BsDiagnostic;
1✔
104
                this.diagnostics.register(err);
1✔
105
            } else {
106
                //if this is not a diagnostic, something else is wrong...
107
                throw e;
×
108
            }
109
            this.printDiagnostics();
1✔
110

111
            //we added diagnostics, so hopefully that draws attention to the underlying issues.
112
            //For now, just use a default options object so we have a functioning program
113
            this.options = util.normalizeConfig({});
1✔
114
        }
115
        this.logger.logLevel = this.options.logLevel;
50✔
116

117
        this.createProgram();
50✔
118

119
        //parse every file in the entire project
120
        await this.loadFiles();
50✔
121
    }
122

123
    public async run(options: BsConfig) {
124
        if (options?.logLevel) {
49✔
125
            this.logger.logLevel = options.logLevel;
4✔
126
        }
127

128
        if (this.isRunning) {
49!
129
            throw new Error('Server is already running');
×
130
        }
131
        this.isRunning = true;
49✔
132

133
        await this.load(options);
49✔
134

135
        if (this.options.watch) {
49!
136
            this.logger.log('Starting compilation in watch mode...');
×
137
            await this.runOnce();
×
138
            this.enableWatchMode();
×
139
        } else {
140
            await this.runOnce();
49✔
141
        }
142
    }
143

144
    protected createProgram() {
145
        this.program = new Program(this.options, this.logger, this.plugins, this.diagnostics);
52✔
146

147
        this.plugins.emit('afterProgramCreate', {
52✔
148
            builder: this,
149
            program: this.program
150
        });
151

152
        return this.program;
52✔
153
    }
154

155
    protected loadPlugins() {
156
        const cwd = this.options.cwd ?? process.cwd();
49!
157
        const plugins = util.loadPlugins(
49✔
158
            cwd,
159
            this.options.plugins ?? [],
147!
160
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
161
        );
162
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`);
49!
163
        for (let plugin of plugins) {
49✔
164
            this.plugins.add(plugin);
×
165
        }
166
        this.plugins.emit('onPluginConfigure', {
49✔
167
            builder: this
168
        });
169

170
        this.plugins.emit('beforeProgramCreate', {
49✔
171
            builder: this
172
        });
173
    }
174

175
    /**
176
     * `require()` every options.require path
177
     */
178
    protected loadRequires() {
179
        for (const dep of this.options.require ?? []) {
49✔
180
            requireRelative(dep, this.options.cwd);
2✔
181
        }
182
    }
183

184
    private clearConsole() {
185
        if (this.allowConsoleClearing) {
46✔
186
            util.clearConsole();
8✔
187
        }
188
    }
189

190
    /**
191
     * A handle for the watch mode interval that keeps the process alive.
192
     * We need this so we can clear it if the builder is disposed
193
     */
194
    private watchInterval: NodeJS.Timer | undefined;
195

196
    public enableWatchMode() {
UNCOV
197
        this.watcher = new Watcher(this.options);
×
UNCOV
198
        if (this.watchInterval) {
×
UNCOV
199
            clearInterval(this.watchInterval);
×
200
        }
201
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
202
        this.watchInterval = setInterval(() => { }, 1073741824);
×
203

204
        //clear the console
205
        this.clearConsole();
×
206

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

209
        //add each set of files to the file watcher
210
        for (let fileObject of fileObjects) {
×
UNCOV
211
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
UNCOV
212
            this.watcher.watch(src);
×
213
        }
214

215
        this.logger.log('Watching for file changes...');
×
216

UNCOV
217
        let debouncedRunOnce = debounce(async () => {
×
218
            this.logger.log('File change detected. Starting incremental compilation...');
×
UNCOV
219
            await this.runOnce();
×
220
            this.logger.log(`Watching for file changes.`);
×
221
        }, 50);
222

223
        //on any file watcher event
UNCOV
224
        this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises
×
UNCOV
225
            if (!this.program) {
×
UNCOV
226
                throw new Error('Internal invariant exception: somehow file watcher ran before `ProgramBuilder.run()`');
×
227
            }
228
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
229
            if (event === 'add' || event === 'change') {
×
UNCOV
230
                const fileObj = {
×
231
                    src: thePath,
232
                    dest: rokuDeploy.getDestPath(
233
                        thePath,
234
                        this.program.options.files,
235
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
236
                        util.driveLetterToLower(this.rootDir)
237
                    )
238
                };
UNCOV
239
                this.program.setFile(
×
240
                    fileObj,
241
                    await this.getFileContents(fileObj.src)
242
                );
UNCOV
243
            } else if (event === 'unlink') {
×
UNCOV
244
                this.program.removeFile(thePath);
×
245
            }
246
            //wait for change events to settle, and then execute `run`
247
            await debouncedRunOnce();
×
248
        });
249
    }
250

251
    /**
252
     * The rootDir for this program.
253
     */
254
    public get rootDir() {
255
        if (!this.program) {
8!
UNCOV
256
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
257
        }
258
        return this.program.options.rootDir;
8✔
259
    }
260

261
    /**
262
     * A method that is used to cancel a previous run task.
263
     * Does nothing if previous run has completed or was already canceled
264
     */
265
    private cancelLastRun = () => {
68✔
266
        return Promise.resolve();
46✔
267
    };
268

269
    /**
270
     * Run the entire process exactly one time.
271
     */
272
    private runOnce() {
273
        //clear the console
274
        this.clearConsole();
46✔
275
        let cancellationToken = { isCanceled: false };
46✔
276
        //wait for the previous run to complete
277
        let runPromise = this.cancelLastRun().then(() => {
46✔
278
            //start the new run
279
            return this._runOnce(cancellationToken);
46✔
280
        }) as any;
281

282
        //a function used to cancel this run
283
        this.cancelLastRun = () => {
46✔
UNCOV
284
            cancellationToken.isCanceled = true;
×
UNCOV
285
            return runPromise;
×
286
        };
287
        return runPromise;
46✔
288
    }
289

290
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
291
        if (this.options?.showDiagnosticsInConsole === false) {
53!
292
            return;
37✔
293
        }
294
        if (!diagnostics) {
16✔
295
            diagnostics = this.getDiagnostics();
6✔
296
        }
297

298
        //group the diagnostics by file
299
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
16✔
300
        for (let diagnostic of diagnostics) {
16✔
301
            const diagnosticFileKey = diagnostic.location?.uri ?? 'no-uri';
7✔
302
            if (!diagnosticsByFile[diagnosticFileKey]) {
7✔
303
                diagnosticsByFile[diagnosticFileKey] = [];
6✔
304
            }
305
            diagnosticsByFile[diagnosticFileKey].push(diagnostic);
7✔
306
        }
307

308
        //get printing options
309
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
310
        const { cwd, emitFullPaths } = options;
16✔
311

312
        let fileUris = Object.keys(diagnosticsByFile).sort();
16✔
313
        for (let fileUri of fileUris) {
16✔
314
            let diagnosticsForFile = diagnosticsByFile[fileUri];
6✔
315
            //sort the diagnostics in line and column order
316
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
317
                return (
1✔
318
                    (a.location?.range?.start.line ?? -1) - (b.location?.range?.start.line ?? -1) ||
20!
319
                    (a.location?.range?.start.character ?? -1) - (b.location?.range?.start.character ?? -1)
18!
320
                );
321
            });
322

323
            let filePath = util.uriToPath(fileUri);
6✔
324
            if (!emitFullPaths) {
6!
325
                filePath = path.relative(cwd, filePath);
6✔
326
            }
327
            //load the file text
328
            const file = this.program?.getFile(fileUri);
6!
329
            //get the file's in-memory contents if available
330
            const lines = (file as BrsFile)?.fileContents?.split(/\r?\n/g) ?? [];
6!
331

332
            for (let diagnostic of sortedDiagnostics) {
6✔
333
                //default the severity to error if undefined
334
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
7✔
335
                let relatedInformation = (util.toDiagnostic(diagnostic, diagnostic.source)?.relatedInformation ?? []).map(x => {
7!
UNCOV
336
                    let relatedInfoFilePath = util.uriToPath(x.location?.uri);
×
UNCOV
337
                    if (!emitFullPaths) {
×
UNCOV
338
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
339
                    }
340
                    return {
×
341
                        filePath: relatedInfoFilePath,
342
                        range: x.location.range,
343
                        message: x.message
344
                    };
345
                });
346
                //format output
347
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
7✔
348
            }
349
        }
350
    }
351

352
    /**
353
     * Run the process once, allowing cancelability.
354
     * NOTE: This should only be called by `runOnce`.
355
     */
356
    private async _runOnce(cancellationToken: { isCanceled: any }) {
357
        let wereDiagnosticsPrinted = false;
46✔
358
        try {
46✔
359
            //maybe cancel?
360
            if (cancellationToken.isCanceled === true) {
46!
UNCOV
361
                return -1;
×
362
            }
363
            //validate program
364
            this.validateProject();
46✔
365

366
            //maybe cancel?
367
            if (cancellationToken.isCanceled === true) {
46!
UNCOV
368
                return -1;
×
369
            }
370

371
            const diagnostics = this.getDiagnostics();
46✔
372
            this.printDiagnostics(diagnostics);
46✔
373
            wereDiagnosticsPrinted = true;
46✔
374
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
46✔
375

376
            if (errorCount > 0) {
46✔
377
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
378
                return errorCount;
1✔
379
            }
380

381
            //create the deployment package (and transpile as well)
382
            await this.createPackageIfEnabled();
45✔
383

384
            //maybe cancel?
385
            if (cancellationToken.isCanceled === true) {
45!
UNCOV
386
                return -1;
×
387
            }
388

389
            //deploy the package
390
            await this.deployPackageIfEnabled();
45✔
391

392
            return 0;
45✔
393
        } catch (e) {
UNCOV
394
            if (wereDiagnosticsPrinted === false) {
×
UNCOV
395
                this.printDiagnostics();
×
396
            }
397
            throw e;
×
398
        }
399
    }
400

401
    private async createPackageIfEnabled() {
402
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
45✔
403

404
            //transpile the project
405
            await this.transpile();
6✔
406

407
            //create the zip file if configured to do so
408
            if (this.options.createPackage !== false || this.options.deploy) {
6✔
409
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
410
                    await rokuDeploy.zipPackage({
2✔
411
                        ...this.options,
412
                        logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
413
                        outDir: util.getOutDir(this.options),
414
                        outFile: path.basename(this.options.outFile)
415
                    });
416
                });
417
            }
418
        }
419
    }
420

421
    private buildThrottler = new Throttler(0);
68✔
422

423
    /**
424
     * Build the entire project and place the contents into the staging directory
425
     */
426
    public async build() {
427
        await this.buildThrottler.run(async () => {
6✔
428
            //get every file referenced by the files array
429
            let fileMap = Object.values(this.program.files).map(x => {
6✔
430
                return {
10✔
431
                    src: x.srcPath,
432
                    dest: x.destPath
433
                };
434
            });
435

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

439
            for (let fileEntry of fileMap) {
6✔
440
                if (this.program!.hasFile(fileEntry.src) === false) {
10!
UNCOV
441
                    filteredFileMap.push(fileEntry);
×
442
                }
443
            }
444

445
            await this.logger.time(LogLevel.log, ['Building'], async () => {
6✔
446
                //transpile any brighterscript files
447
                await this.program!.build();
6✔
448
            });
449
        });
450
    }
451

452
    /**
453
     * Transpiles the entire program into the staging folder
454
     * @deprecated use `.build()` instead
455
     */
456
    public async transpile() {
457
        return this.build();
6✔
458
    }
459

460
    private async deployPackageIfEnabled() {
461
        //deploy the project if configured to do so
462
        if (this.options.deploy) {
45!
UNCOV
463
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
UNCOV
464
                await rokuDeploy.publish({
×
465
                    ...this.options,
466
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
467
                    outDir: util.getOutDir(this.options),
468
                    outFile: path.basename(this.options.outFile)
469
                });
470
            });
471
        }
472
    }
473

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

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

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

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

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

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

522
    /**
523
     * Remove all files from the program that are in the specified folder path
524
     * @param srcPath the path to the
525
     */
526
    public removeFilesInFolder(srcPath: string) {
UNCOV
527
        for (let filePath in this.program.files) {
×
528
            //if the file path starts with the parent path and the file path does not exactly match the folder path
UNCOV
529
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
530
                this.program.removeFile(filePath);
×
531
            }
532
        }
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();
46✔
541
    }
542

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