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

rokucommunity / brighterscript / #12930

13 Aug 2024 05:02PM UTC coverage: 86.193% (-1.7%) from 87.933%
#12930

push

web-flow
Merge 58ad447a2 into 0e968f1c3

10630 of 13125 branches covered (80.99%)

Branch coverage included in aggregate %.

6675 of 7284 new or added lines in 99 files covered. (91.64%)

84 existing lines in 18 files now uncovered.

12312 of 13492 relevant lines covered (91.25%)

26865.48 hits per line

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

69.53
/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) => {
63✔
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;
63✔
35

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

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

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

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

67
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
68
        if (!this.program) {
×
69
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
70
        }
NEW
71
        if (!diagnostic.location) {
×
NEW
72
            diagnostic.location = {
×
73
                uri: util.pathToUri(srcPath),
74
                range: util.createRange(0, 0, 0, 0)
75
            };
76
        } else {
NEW
77
            diagnostic.location.uri = util.pathToUri(srcPath);
×
78
        }
NEW
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 {
48✔
91
            this.options = util.normalizeAndResolveConfig(options);
48✔
92
            if (this.options.noProject) {
47!
93
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
94
            } else if (this.options.project) {
47✔
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`);
38✔
98
            }
99
            this.loadRequires();
47✔
100
            this.loadPlugins();
47✔
101
        } catch (e: any) {
102
            if (e?.location && e.message && e.code) {
1!
103
                let err = e as BsDiagnostic;
1✔
104
                this.diagnostics.register(err);
1✔
105
            } else {
106
                //if this is not a diagnostic, something else is wrong...
107
                throw e;
×
108
            }
109
            this.printDiagnostics();
1✔
110

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

117
        this.createProgram();
48✔
118

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

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

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

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

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

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

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

150
        return this.program;
49✔
151
    }
152

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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