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

rokucommunity / brighterscript / #13318

25 Nov 2024 02:52PM UTC coverage: 86.872% (-2.2%) from 89.121%
#13318

push

web-flow
Merge d9b225566 into 2a6afd921

11934 of 14527 branches covered (82.15%)

Branch coverage included in aggregate %.

300 of 316 new or added lines in 31 files covered. (94.94%)

876 existing lines in 46 files now uncovered.

12960 of 14129 relevant lines covered (91.73%)

32482.16 hits per line

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

70.02
/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) => {
66✔
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;
66✔
35

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

44
    public addFileResolver(fileResolver: FileResolver) {
45
        this.fileResolvers.push(fileResolver);
104✔
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
        }
UNCOV
62
        throw new Error(`Could not load file "${srcPath}"`);
×
63
    }
64

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

67
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
UNCOV
68
        if (!this.program) {
×
UNCOV
69
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
70
        }
UNCOV
71
        if (!diagnostic.location) {
×
UNCOV
72
            diagnostic.location = {
×
73
                uri: util.pathToUri(srcPath),
74
                range: util.createRange(0, 0, 0, 0)
75
            };
76
        } else {
UNCOV
77
            diagnostic.location.uri = util.pathToUri(srcPath);
×
78
        }
UNCOV
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 {
49✔
91
            this.options = util.normalizeAndResolveConfig(options);
49✔
92
            if (this.options.noProject) {
48!
UNCOV
93
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
94
            } else if (this.options.project) {
48✔
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`);
39✔
98
            }
99
            this.loadRequires();
48✔
100
            this.loadPlugins();
48✔
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...
UNCOV
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;
49✔
116

117
        this.createProgram();
49✔
118

119
        //parse every file in the entire project
120
        await this.loadFiles();
49✔
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!
UNCOV
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!
UNCOV
136
            this.logger.log('Starting compilation in watch mode...');
×
UNCOV
137
            await this.runOnce();
×
UNCOV
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);
51✔
146

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

152
        return this.program;
51✔
153
    }
154

155
    protected loadPlugins() {
156
        const cwd = this.options.cwd ?? process.cwd();
48!
157
        const plugins = util.loadPlugins(
48✔
158
            cwd,
159
            this.options.plugins ?? [],
144!
UNCOV
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}"`);
48!
163
        for (let plugin of plugins) {
48✔
UNCOV
164
            this.plugins.add(plugin);
×
165
        }
166

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

418
    private buildThrottler = new Throttler(0);
66✔
419

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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