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

rokucommunity / brighterscript / #13816

05 Apr 2024 01:47PM UTC coverage: 89.047% (+0.6%) from 88.473%
#13816

push

TwitchBronBron
Fix completions crash

6410 of 7642 branches covered (83.88%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

181 existing lines in 11 files now uncovered.

9281 of 9979 relevant lines covered (93.01%)

1693.04 hits per line

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

69.9
/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 { BscFile, 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 { Logger, LogLevel } from './Logger';
1✔
11
import PluginInterface from './PluginInterface';
1✔
12
import * as diagnosticUtils from './diagnosticUtils';
1✔
13
import * as fsExtra from 'fs-extra';
1✔
14
import * as requireRelative from 'require-relative';
1✔
15
import { Throttler } from './Throttler';
1✔
16
import { URI } from 'vscode-uri';
1✔
17

18
/**
19
 * A runner class that handles
20
 */
21
export class ProgramBuilder {
1✔
22

23
    public constructor() {
24
        //add the default file resolver (used to load source file contents).
25
        this.addFileResolver((filePath) => {
85✔
26
            return fsExtra.readFile(filePath).then((value) => {
33✔
27
                return value.toString();
33✔
28
            });
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;
85✔
35

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

44
    /**
45
     * Add file resolvers that will be able to provide file contents before loading from the file system
46
     * @param fileResolvers a list of file resolvers
47
     */
48
    public addFileResolver(...fileResolvers: FileResolver[]) {
49
        this.fileResolvers.push(...fileResolvers);
85✔
50
    }
51

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

69
    /**
70
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
71
     */
72
    private staticDiagnostics = [] as BsDiagnostic[];
85✔
73

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

93
    public getDiagnostics() {
94
        return [
148✔
95
            ...this.staticDiagnostics,
96
            ...(this.program?.getDiagnostics() ?? [])
888!
97
        ];
98
    }
99

100
    public async run(options: BsConfig & { skipInitialValidation?: boolean }) {
101
        this.logger.logLevel = options.logLevel as LogLevel;
71✔
102

103
        if (this.isRunning) {
71!
UNCOV
104
            throw new Error('Server is already running');
×
105
        }
106
        this.isRunning = true;
71✔
107
        try {
71✔
108
            this.options = util.normalizeAndResolveConfig(options);
71✔
109
            if (this.options.noProject) {
70!
UNCOV
110
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
111
            } else if (this.options.project) {
70✔
112
                this.logger.log(`Using config file: "${this.options.project}"`);
25✔
113
            } else {
114
                this.logger.log(`No bsconfig.json file found, using default options`);
45✔
115
            }
116
            this.loadRequires();
70✔
117
            this.loadPlugins();
70✔
118
        } catch (e: any) {
119
            if (e?.file && e.message && e.code) {
1!
120
                let err = e as BsDiagnostic;
1✔
121
                this.staticDiagnostics.push(err);
1✔
122
            } else {
123
                //if this is not a diagnostic, something else is wrong...
UNCOV
124
                throw e;
×
125
            }
126
            this.printDiagnostics();
1✔
127

128
            //we added diagnostics, so hopefully that draws attention to the underlying issues.
129
            //For now, just use a default options object so we have a functioning program
130
            this.options = util.normalizeConfig({});
1✔
131
        }
132
        this.logger.logLevel = this.options.logLevel as LogLevel;
71✔
133

134
        this.createProgram();
71✔
135

136
        //parse every file in the entire project
137
        await this.loadAllFilesAST();
71✔
138

139
        if (this.options.watch) {
71!
UNCOV
140
            this.logger.log('Starting compilation in watch mode...');
×
UNCOV
141
            await this.runOnce({
×
142
                skipValidation: options.skipInitialValidation
143
            });
UNCOV
144
            this.enableWatchMode();
×
145
        } else {
146
            await this.runOnce({
71✔
147
                skipValidation: options.skipInitialValidation
148
            });
149
        }
150
    }
151

152
    protected createProgram() {
153
        this.program = new Program(this.options, this.logger, this.plugins);
72✔
154

155
        this.plugins.emit('afterProgramCreate', this.program);
72✔
156

157
        return this.program;
72✔
158
    }
159

160
    protected loadPlugins() {
161
        const cwd = this.options.cwd ?? process.cwd();
70!
162
        const plugins = util.loadPlugins(
70✔
163
            cwd,
164
            this.options.plugins ?? [],
210!
UNCOV
165
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
166
        );
167
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`);
70!
168
        for (let plugin of plugins) {
70✔
UNCOV
169
            this.plugins.add(plugin);
×
170
        }
171

172
        this.plugins.emit('beforeProgramCreate', this);
70✔
173
    }
174

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

184
    private clearConsole() {
185
        if (this.allowConsoleClearing) {
69!
186
            util.clearConsole();
69✔
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() {
197
        this.watcher = new Watcher(this.options);
×
UNCOV
198
        if (this.watchInterval) {
×
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
UNCOV
205
        this.clearConsole();
×
206

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) {
×
211
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
212
            this.watcher.watch(src);
×
213
        }
214

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

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
            }
UNCOV
228
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
UNCOV
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
                };
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`
UNCOV
247
            await debouncedRunOnce();
×
248
        });
249
    }
250

251
    /**
252
     * The rootDir for this program.
253
     */
254
    public get rootDir() {
UNCOV
255
        if (!this.program) {
×
UNCOV
256
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
257
        }
UNCOV
258
        return this.program.options.rootDir;
×
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 = () => {
85✔
266
        return Promise.resolve();
69✔
267
    };
268

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

285
        //a function used to cancel this run
286
        this.cancelLastRun = () => {
69✔
UNCOV
287
            cancellationToken.isCanceled = true;
×
UNCOV
288
            return runPromise;
×
289
        };
290
        return runPromise;
69✔
291
    }
292

293
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
294
        if (this.options?.showDiagnosticsInConsole === false) {
75!
295
            return;
64✔
296
        }
297
        if (!diagnostics) {
11✔
298
            diagnostics = this.getDiagnostics();
5✔
299
        }
300

301
        //group the diagnostics by file
302
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
11✔
303
        for (let diagnostic of diagnostics) {
11✔
304
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
5!
305
                diagnosticsByFile[diagnostic.file.srcPath] = [];
5✔
306
            }
307
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
5✔
308
        }
309

310
        //get printing options
311
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
11✔
312
        const { cwd, emitFullPaths } = options;
11✔
313

314
        let srcPaths = Object.keys(diagnosticsByFile).sort();
11✔
315
        for (let srcPath of srcPaths) {
11✔
316
            let diagnosticsForFile = diagnosticsByFile[srcPath];
5✔
317
            //sort the diagnostics in line and column order
318
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
5✔
UNCOV
319
                return (
×
320
                    a.range.start.line - b.range.start.line ||
×
321
                    a.range.start.character - b.range.start.character
322
                );
323
            });
324

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

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

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

370
            //maybe cancel?
371
            if (options.cancellationToken.isCanceled === true) {
69!
UNCOV
372
                return -1;
×
373
            }
374

375
            const diagnostics = this.getDiagnostics();
69✔
376
            this.printDiagnostics(diagnostics);
69✔
377
            wereDiagnosticsPrinted = true;
69✔
378
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
69✔
379

380
            if (errorCount > 0) {
69✔
381
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
382
                return errorCount;
1✔
383
            }
384

385
            //create the deployment package (and transpile as well)
386
            await this.createPackageIfEnabled();
68✔
387

388
            //maybe cancel?
389
            if (options.cancellationToken.isCanceled === true) {
68!
UNCOV
390
                return -1;
×
391
            }
392

393
            //deploy the package
394
            await this.deployPackageIfEnabled();
68✔
395

396
            return 0;
68✔
397
        } catch (e) {
UNCOV
398
            if (wereDiagnosticsPrinted === false) {
×
UNCOV
399
                this.printDiagnostics();
×
400
            }
UNCOV
401
            throw e;
×
402
        }
403
    }
404

405
    private async createPackageIfEnabled() {
406
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
68✔
407

408
            //transpile the project
409
            await this.transpile();
4✔
410

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

425
    private transpileThrottler = new Throttler(0);
85✔
426
    /**
427
     * Transpiles the entire program into the staging folder
428
     */
429
    public async transpile() {
430
        await this.transpileThrottler.run(async () => {
4✔
431
            let options = util.cwdWork(this.options.cwd, () => {
4✔
432
                return rokuDeploy.getOptions({
4✔
433
                    ...this.options,
434
                    logLevel: this.options.logLevel as LogLevel,
435
                    outDir: util.getOutDir(this.options),
436
                    outFile: path.basename(this.options.outFile)
437

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

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

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

448
            for (let fileEntry of fileMap) {
4✔
449
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
450
                    filteredFileMap.push(fileEntry);
2✔
451
                }
452
            }
453

454
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
455

456
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
457
                //prepublish all non-program-loaded files to staging
458
                await rokuDeploy.prepublishToStaging({
4✔
459
                    ...options,
460
                    files: filteredFileMap
461
                });
462
            });
463

464
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
465
            this.plugins.emit('beforePublish', this, fileMap);
4✔
466

467
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
468
                //transpile any brighterscript files
469
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
470
            });
471

472
            this.plugins.emit('afterPublish', this, fileMap);
4✔
473
        });
474
    }
475

476
    private async deployPackageIfEnabled() {
477
        //deploy the project if configured to do so
478
        if (this.options.deploy) {
68!
UNCOV
479
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
UNCOV
480
                await rokuDeploy.publish({
×
481
                    ...this.options,
482
                    logLevel: this.options.logLevel as LogLevel,
483
                    outDir: util.getOutDir(this.options),
484
                    outFile: path.basename(this.options.outFile)
485
                });
486
            });
487
        }
488
    }
489

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

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

504
            for (const file of files) {
73✔
505
                // source files (.brs, .bs, .xml)
506
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
46✔
507
                    sourceFiles.push(file);
37✔
508

509
                    // typedef files (.d.bs)
510
                } else if (/\.d\.bs$/i.test(file.dest)) {
9✔
511
                    typedefFiles.push(file);
2✔
512

513
                    // manifest file
514
                } else if (/^manifest$/i.test(file.dest)) {
7✔
515
                    manifestFile = file;
6✔
516
                }
517
            }
518

519
            if (manifestFile) {
73✔
520
                this.program!.loadManifest(manifestFile, false);
6✔
521
            }
522

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

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

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

559
    public dispose() {
560
        if (this.watcher) {
79!
UNCOV
561
            this.watcher.dispose();
×
562
        }
563
        if (this.program) {
79!
564
            this.program.dispose?.();
79!
565
        }
566
        if (this.watchInterval) {
79!
UNCOV
567
            clearInterval(this.watchInterval);
×
568
        }
569
    }
570
}
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