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

rokucommunity / brighterscript / #15046

03 Oct 2022 01:55PM UTC coverage: 87.532% (-0.3%) from 87.808%
#15046

push

TwitchBronBron
0.59.0

5452 of 6706 branches covered (81.3%)

Branch coverage included in aggregate %.

8259 of 8958 relevant lines covered (92.2%)

1521.92 hits per line

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

71.26
/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 } 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

16
/**
17
 * A runner class that handles
18
 */
19
export class ProgramBuilder {
1✔
20

21
    public constructor() {
22
        //add the default file resolver (used to load source file contents).
23
        this.addFileResolver((filePath) => {
52✔
24
            return fsExtra.readFile(filePath).then((value) => {
20✔
25
                return value.toString();
20✔
26
            });
27
        });
28
    }
29
    /**
30
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
31
     */
32
    public allowConsoleClearing = true;
52✔
33

34
    public options: BsConfig;
35
    private isRunning = false;
52✔
36
    private watcher: Watcher;
37
    public program: Program;
38
    public logger = new Logger();
52✔
39
    public plugins: PluginInterface = new PluginInterface([], this.logger);
52✔
40
    private fileResolvers = [] as FileResolver[];
52✔
41

42
    public addFileResolver(fileResolver: FileResolver) {
43
        this.fileResolvers.push(fileResolver);
85✔
44
    }
45

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

63
    /**
64
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
65
     */
66
    private staticDiagnostics = [] as BsDiagnostic[];
52✔
67

68
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
69
        let file: BscFile = this.program.getFile(srcPath);
×
70
        if (!file) {
×
71
            file = {
×
72
                pkgPath: this.program.getPkgPath(srcPath),
73
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
74
                srcPath: srcPath,
75
                getDiagnostics: () => {
76
                    return [<any>diagnostic];
×
77
                }
78
            } as BscFile;
79
        }
80
        diagnostic.file = file;
×
81
        this.staticDiagnostics.push(<any>diagnostic);
×
82
    }
83

84
    public getDiagnostics() {
85
        return [
98✔
86
            ...this.staticDiagnostics,
87
            ...(this.program?.getDiagnostics() ?? [])
588!
88
        ];
89
    }
90

91
    public async run(options: BsConfig) {
92
        this.logger.logLevel = options.logLevel as LogLevel;
41✔
93

94
        if (this.isRunning) {
41!
95
            throw new Error('Server is already running');
×
96
        }
97
        this.isRunning = true;
41✔
98
        try {
41✔
99
            this.options = util.normalizeAndResolveConfig(options);
41✔
100
            if (this.options.project) {
40✔
101
                this.logger.log(`Using config file: "${this.options.project}"`);
9✔
102
            } else {
103
                this.logger.log(`No bsconfig.json file found, using default options`);
31✔
104
            }
105
            this.loadRequires();
40✔
106
            this.loadPlugins();
40✔
107
        } catch (e: any) {
108
            if (e?.file && e.message && e.code) {
1!
109
                let err = e as BsDiagnostic;
1✔
110
                this.staticDiagnostics.push(err);
1✔
111
            } else {
112
                //if this is not a diagnostic, something else is wrong...
113
                throw e;
×
114
            }
115
            this.printDiagnostics();
1✔
116

117
            //we added diagnostics, so hopefully that draws attention to the underlying issues.
118
            //For now, just use a default options object so we have a functioning program
119
            this.options = util.normalizeConfig({});
1✔
120
        }
121
        this.logger.logLevel = this.options.logLevel as LogLevel;
41✔
122

123
        this.program = this.createProgram();
41✔
124

125
        //parse every file in the entire project
126
        await this.loadAllFilesAST();
41✔
127

128
        if (this.options.watch) {
41!
129
            this.logger.log('Starting compilation in watch mode...');
×
130
            await this.runOnce();
×
131
            this.enableWatchMode();
×
132
        } else {
133
            await this.runOnce();
41✔
134
        }
135
    }
136

137
    protected createProgram() {
138
        const program = new Program(this.options, undefined, this.plugins);
41✔
139

140
        this.plugins.emit('afterProgramCreate', program);
41✔
141
        return program;
41✔
142
    }
143

144
    protected loadPlugins() {
145
        const cwd = this.options.cwd ?? process.cwd();
40!
146
        const plugins = util.loadPlugins(
40✔
147
            cwd,
148
            this.options.plugins ?? [],
120!
149
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
150
        );
151
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`);
40!
152
        for (let plugin of plugins) {
40✔
153
            this.plugins.add(plugin);
×
154
        }
155

156
        this.plugins.emit('beforeProgramCreate', this);
40✔
157
    }
158

159
    /**
160
     * `require()` every options.require path
161
     */
162
    protected loadRequires() {
163
        for (const dep of this.options.require ?? []) {
40✔
164
            requireRelative(dep, this.options.cwd);
2✔
165
        }
166
    }
167

168
    private clearConsole() {
169
        if (this.allowConsoleClearing) {
39✔
170
            util.clearConsole();
6✔
171
        }
172
    }
173

174
    /**
175
     * A handle for the watch mode interval that keeps the process alive.
176
     * We need this so we can clear it if the builder is disposed
177
     */
178
    private watchInterval: NodeJS.Timer;
179

180
    public enableWatchMode() {
181
        this.watcher = new Watcher(this.options);
×
182
        if (this.watchInterval) {
×
183
            clearInterval(this.watchInterval);
×
184
        }
185
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
186
        this.watchInterval = setInterval(() => { }, 1073741824);
×
187

188
        //clear the console
189
        this.clearConsole();
×
190

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

193
        //add each set of files to the file watcher
194
        for (let fileObject of fileObjects) {
×
195
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
196
            this.watcher.watch(src);
×
197
        }
198

199
        this.logger.log('Watching for file changes...');
×
200

201
        let debouncedRunOnce = debounce(async () => {
×
202
            this.logger.log('File change detected. Starting incremental compilation...');
×
203
            await this.runOnce();
×
204
            this.logger.log(`Watching for file changes.`);
×
205
        }, 50);
206

207
        //on any file watcher event
208
        this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises
×
209
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
210
            if (event === 'add' || event === 'change') {
×
211
                const fileObj = {
×
212
                    src: thePath,
213
                    dest: rokuDeploy.getDestPath(
214
                        thePath,
215
                        this.program.options.files,
216
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
217
                        util.driveLetterToLower(this.rootDir)
218
                    )
219
                };
220
                this.program.setFile(
×
221
                    fileObj,
222
                    await this.getFileContents(fileObj.src)
223
                );
224
            } else if (event === 'unlink') {
×
225
                this.program.removeFile(thePath);
×
226
            }
227
            //wait for change events to settle, and then execute `run`
228
            await debouncedRunOnce();
×
229
        });
230
    }
231

232
    /**
233
     * The rootDir for this program.
234
     */
235
    public get rootDir() {
236
        return this.program.options.rootDir;
7✔
237
    }
238

239
    /**
240
     * A method that is used to cancel a previous run task.
241
     * Does nothing if previous run has completed or was already canceled
242
     */
243
    private cancelLastRun = () => {
52✔
244
        return Promise.resolve();
39✔
245
    };
246

247
    /**
248
     * Run the entire process exactly one time.
249
     */
250
    private runOnce() {
251
        //clear the console
252
        this.clearConsole();
39✔
253
        let cancellationToken = { isCanceled: false };
39✔
254
        //wait for the previous run to complete
255
        let runPromise = this.cancelLastRun().then(() => {
39✔
256
            //start the new run
257
            return this._runOnce(cancellationToken);
39✔
258
        }) as any;
259

260
        //a function used to cancel this run
261
        this.cancelLastRun = () => {
39✔
262
            cancellationToken.isCanceled = true;
×
263
            return runPromise;
×
264
        };
265
        return runPromise;
39✔
266
    }
267

268
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
269
        if (this.options?.showDiagnosticsInConsole === false) {
45!
270
            return;
32✔
271
        }
272
        if (!diagnostics) {
13✔
273
            diagnostics = this.getDiagnostics();
5✔
274
        }
275

276
        //group the diagnostics by file
277
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
13✔
278
        for (let diagnostic of diagnostics) {
13✔
279
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
5!
280
                diagnosticsByFile[diagnostic.file.srcPath] = [];
5✔
281
            }
282
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
5✔
283
        }
284

285
        //get printing options
286
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
13✔
287
        const { cwd, emitFullPaths } = options;
13✔
288

289
        let srcPaths = Object.keys(diagnosticsByFile).sort();
13✔
290
        for (let srcPath of srcPaths) {
13✔
291
            let diagnosticsForFile = diagnosticsByFile[srcPath];
5✔
292
            //sort the diagnostics in line and column order
293
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
5✔
294
                return (
×
295
                    a.range.start.line - b.range.start.line ||
×
296
                    a.range.start.character - b.range.start.character
297
                );
298
            });
299

300
            let filePath = srcPath;
5✔
301
            if (!emitFullPaths) {
5!
302
                filePath = path.relative(cwd, filePath);
5✔
303
            }
304
            //load the file text
305
            const file = this.program?.getFile(srcPath);
5!
306
            //get the file's in-memory contents if available
307
            const lines = file?.fileContents?.split(/\r?\n/g) ?? [];
5✔
308

309
            for (let diagnostic of sortedDiagnostics) {
5✔
310
                //default the severity to error if undefined
311
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
5!
312
                //format output
313
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic);
5✔
314
            }
315
        }
316
    }
317

318
    /**
319
     * Run the process once, allowing cancelability.
320
     * NOTE: This should only be called by `runOnce`.
321
     * @param cancellationToken
322
     */
323
    private async _runOnce(cancellationToken: { isCanceled: any }) {
324
        let wereDiagnosticsPrinted = false;
39✔
325
        try {
39✔
326
            //maybe cancel?
327
            if (cancellationToken.isCanceled === true) {
39!
328
                return -1;
×
329
            }
330
            //validate program
331
            this.validateProject();
39✔
332

333
            //maybe cancel?
334
            if (cancellationToken.isCanceled === true) {
39!
335
                return -1;
×
336
            }
337

338
            const diagnostics = this.getDiagnostics();
39✔
339
            this.printDiagnostics(diagnostics);
39✔
340
            wereDiagnosticsPrinted = true;
39✔
341
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
39✔
342

343
            if (errorCount > 0) {
39✔
344
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
345
                return errorCount;
1✔
346
            }
347

348
            //create the deployment package (and transpile as well)
349
            await this.createPackageIfEnabled();
38✔
350

351
            //maybe cancel?
352
            if (cancellationToken.isCanceled === true) {
38!
353
                return -1;
×
354
            }
355

356
            //deploy the package
357
            await this.deployPackageIfEnabled();
38✔
358

359
            return 0;
38✔
360
        } catch (e) {
361
            if (wereDiagnosticsPrinted === false) {
×
362
                this.printDiagnostics();
×
363
            }
364
            throw e;
×
365
        }
366
    }
367

368
    private async createPackageIfEnabled() {
369
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
38✔
370

371
            //transpile the project
372
            await this.transpile();
4✔
373

374
            //create the zip file if configured to do so
375
            if (this.options.createPackage !== false || this.options.deploy) {
4✔
376
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
377
                    await rokuDeploy.zipPackage({
2✔
378
                        ...this.options,
379
                        logLevel: this.options.logLevel as LogLevel,
380
                        outDir: util.getOutDir(this.options),
381
                        outFile: path.basename(this.options.outFile)
382
                    });
383
                });
384
            }
385
        }
386
    }
387

388
    /**
389
     * Transpiles the entire program into the staging folder
390
     */
391
    public async transpile() {
392
        let options = util.cwdWork(this.options.cwd, () => {
4✔
393
            return rokuDeploy.getOptions({
4✔
394
                ...this.options,
395
                logLevel: this.options.logLevel as LogLevel,
396
                outDir: util.getOutDir(this.options),
397
                outFile: path.basename(this.options.outFile)
398
            });
399
        });
400

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

404
        //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
405
        let filteredFileMap = [] as FileObj[];
4✔
406
        for (let fileEntry of fileMap) {
4✔
407
            if (this.program.hasFile(fileEntry.src) === false) {
4✔
408
                filteredFileMap.push(fileEntry);
2✔
409
            }
410
        }
411

412
        this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
413

414
        await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
415
            //prepublish all non-program-loaded files to staging
416
            await rokuDeploy.prepublishToStaging({
4✔
417
                ...options,
418
                files: filteredFileMap
419
            });
420
        });
421

422
        this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
423
        this.plugins.emit('beforePublish', this, fileMap);
4✔
424

425
        await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
426
            //transpile any brighterscript files
427
            await this.program.transpile(fileMap, options.stagingDir);
4✔
428
        });
429

430
        this.plugins.emit('afterPublish', this, fileMap);
4✔
431
    }
432

433
    private async deployPackageIfEnabled() {
434
        //deploy the project if configured to do so
435
        if (this.options.deploy) {
38!
436
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
437
                await rokuDeploy.publish({
×
438
                    ...this.options,
439
                    logLevel: this.options.logLevel as LogLevel,
440
                    outDir: util.getOutDir(this.options),
441
                    outFile: path.basename(this.options.outFile)
442
                });
443
            });
444
        }
445
    }
446

447
    /**
448
     * Parse and load the AST for every file in the project
449
     */
450
    private async loadAllFilesAST() {
451
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
42✔
452
            let errorCount = 0;
42✔
453
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
42✔
454
                return util.getFilePaths(this.options);
42✔
455
            });
456
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
42✔
457

458
            const typedefFiles = [] as FileObj[];
42✔
459
            const nonTypedefFiles = [] as FileObj[];
42✔
460
            for (const file of files) {
42✔
461
                const srcLower = file.src.toLowerCase();
24✔
462
                if (srcLower.endsWith('.d.bs')) {
24✔
463
                    typedefFiles.push(file);
2✔
464
                } else {
465
                    nonTypedefFiles.push(file);
22✔
466
                }
467
            }
468

469
            //preload every type definition file first, which eliminates duplicate file loading
470
            await Promise.all(
42✔
471
                typedefFiles.map(async (fileObj) => {
472
                    try {
2✔
473
                        this.program.setFile(
2✔
474
                            fileObj,
475
                            await this.getFileContents(fileObj.src)
476
                        );
477
                    } catch (e) {
478
                        //log the error, but don't fail this process because the file might be fixable later
479
                        this.logger.log(e);
×
480
                    }
481
                })
482
            );
483

484
            const acceptableExtensions = ['.bs', '.brs', '.xml'];
42✔
485
            //parse every file other than the type definitions
486
            await Promise.all(
42✔
487
                nonTypedefFiles.map(async (fileObj) => {
488
                    try {
22✔
489
                        let fileExtension = path.extname(fileObj.src).toLowerCase();
22✔
490

491
                        //only process certain file types
492
                        if (acceptableExtensions.includes(fileExtension)) {
22✔
493
                            this.program.setFile(
18✔
494
                                fileObj,
495
                                await this.getFileContents(fileObj.src)
496
                            );
497
                        }
498
                    } catch (e) {
499
                        //log the error, but don't fail this process because the file might be fixable later
500
                        this.logger.log(e);
×
501
                    }
502
                })
503
            );
504
            return errorCount;
42✔
505
        });
506
    }
507

508
    /**
509
     * Remove all files from the program that are in the specified folder path
510
     * @param srcPath the path to the
511
     */
512
    public removeFilesInFolder(srcPath: string) {
513
        for (let filePath in this.program.files) {
×
514
            //if the file path starts with the parent path and the file path does not exactly match the folder path
515
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
516
                this.program.removeFile(filePath);
×
517
            }
518
        }
519
    }
520

521
    /**
522
     * Scan every file and resolve all variable references.
523
     * If no errors were encountered, return true. Otherwise return false.
524
     */
525
    private validateProject() {
526
        this.program.validate();
39✔
527
    }
528

529
    public dispose() {
530
        if (this.watcher) {
17!
531
            this.watcher.dispose();
×
532
        }
533
        if (this.program) {
17!
534
            this.program.dispose?.();
17!
535
        }
536
        if (this.watchInterval) {
17!
537
            clearInterval(this.watchInterval);
×
538
        }
539
    }
540
}
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