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

rokucommunity / brighterscript / #12848

25 Jul 2024 12:51PM UTC coverage: 86.205% (+0.5%) from 85.656%
#12848

push

web-flow
Merge 0c17734d3 into 5f3ffa3fa

10601 of 13088 branches covered (81.0%)

Branch coverage included in aggregate %.

82 of 88 new or added lines in 15 files covered. (93.18%)

2 existing lines in 2 files now uncovered.

12302 of 13480 relevant lines covered (91.26%)

26605.76 hits per line

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

69.61
/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 { URI } from 'vscode-uri';
1✔
19
import { DiagnosticManager } from './DiagnosticManager';
1✔
20

21
/**
22
 * A runner class that handles
23
 */
24
export class ProgramBuilder {
1✔
25

26
    public constructor() {
27
        //add the default file resolver (used to load source file contents).
28
        this.addFileResolver((filePath) => {
63✔
29
            return fsExtra.readFile(filePath);
33✔
30
        });
31
    }
32
    /**
33
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
34
     */
35
    public allowConsoleClearing = true;
63✔
36

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

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

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

66
    public diagnostics = new DiagnosticManager();
63✔
67

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

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

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

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

118
        this.createProgram();
48✔
119

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

124
    public async run(options: BsConfig) {
125
        this.logger.logLevel = options.logLevel as LogLevel;
48✔
126

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

132
        await this.load(options);
48✔
133

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

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

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

151
        return this.program;
49✔
152
    }
153

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

166
        this.plugins.emit('beforeProgramCreate', {
47✔
167
            builder: this
168
        });
169
    }
170

171
    /**
172
     * `require()` every options.require path
173
     */
174
    protected loadRequires() {
175
        for (const dep of this.options.require ?? []) {
47✔
176
            requireRelative(dep, this.options.cwd);
2✔
177
        }
178
    }
179

180
    private clearConsole() {
181
        if (this.allowConsoleClearing) {
46✔
182
            util.clearConsole();
8✔
183
        }
184
    }
185

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

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

200
        //clear the console
201
        this.clearConsole();
×
202

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

205
        //add each set of files to the file watcher
206
        for (let fileObject of fileObjects) {
×
207
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
208
            this.watcher.watch(src);
×
209
        }
210

211
        this.logger.log('Watching for file changes...');
×
212

213
        let debouncedRunOnce = debounce(async () => {
×
214
            this.logger.log('File change detected. Starting incremental compilation...');
×
215
            await this.runOnce();
×
216
            this.logger.log(`Watching for file changes.`);
×
217
        }, 50);
218

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

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

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

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

278
        //a function used to cancel this run
279
        this.cancelLastRun = () => {
46✔
280
            cancellationToken.isCanceled = true;
×
281
            return runPromise;
×
282
        };
283
        return runPromise;
46✔
284
    }
285

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

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

304
        //get printing options
305
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
306
        const { cwd, emitFullPaths } = options;
16✔
307

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

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

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

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

362
            //maybe cancel?
363
            if (cancellationToken.isCanceled === true) {
46!
364
                return -1;
×
365
            }
366

367
            const diagnostics = this.getDiagnostics();
46✔
368
            this.printDiagnostics(diagnostics);
46✔
369
            wereDiagnosticsPrinted = true;
46✔
370
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
46✔
371

372
            if (errorCount > 0) {
46✔
373
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
374
                return errorCount;
1✔
375
            }
376

377
            //create the deployment package (and transpile as well)
378
            await this.createPackageIfEnabled();
45✔
379

380
            //maybe cancel?
381
            if (cancellationToken.isCanceled === true) {
45!
382
                return -1;
×
383
            }
384

385
            //deploy the package
386
            await this.deployPackageIfEnabled();
45✔
387

388
            return 0;
45✔
389
        } catch (e) {
390
            if (wereDiagnosticsPrinted === false) {
×
391
                this.printDiagnostics();
×
392
            }
393
            throw e;
×
394
        }
395
    }
396

397
    private async createPackageIfEnabled() {
398
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
45✔
399

400
            //transpile the project
401
            await this.transpile();
6✔
402

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

417
    private buildThrottler = new Throttler(0);
63✔
418

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

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

435
            for (let fileEntry of fileMap) {
6✔
436
                if (this.program!.hasFile(fileEntry.src) === false) {
10!
437
                    filteredFileMap.push(fileEntry);
×
438
                }
439
            }
440

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

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

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

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

480
            const typedefFiles = [] as FileObj[];
50✔
481
            const allOtherFiles = [] as FileObj[];
50✔
482
            let manifestFile: FileObj | null = null;
50✔
483

484
            for (const file of files) {
50✔
485
                // typedef files
486
                if (/\.d\.bs$/i.test(file.dest)) {
36✔
487
                    typedefFiles.push(file);
2✔
488

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

499
            //load the manifest file first
500
            if (manifestFile) {
50✔
501
                this.program!.loadManifest(manifestFile, false);
7✔
502
            }
503

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

518
    /**
519
     * Remove all files from the program that are in the specified folder path
520
     * @param srcPath the path to the
521
     */
522
    public removeFilesInFolder(srcPath: string) {
523
        for (let filePath in this.program.files) {
×
524
            //if the file path starts with the parent path and the file path does not exactly match the folder path
525
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
526
                this.program.removeFile(filePath);
×
527
            }
528
        }
529
    }
530

531
    /**
532
     * Scan every file and resolve all variable references.
533
     * If no errors were encountered, return true. Otherwise return false.
534
     */
535
    private validateProject() {
536
        this.program.validate();
46✔
537
    }
538

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

© 2025 Coveralls, Inc