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

rokucommunity / brighterscript / #12715

14 Jun 2024 08:20PM UTC coverage: 85.629% (-2.3%) from 87.936%
#12715

push

web-flow
Merge 94311dc0a into 42db50190

10808 of 13500 branches covered (80.06%)

Branch coverage included in aggregate %.

6557 of 7163 new or added lines in 96 files covered. (91.54%)

83 existing lines in 17 files now uncovered.

12270 of 13451 relevant lines covered (91.22%)

26531.5 hits per line

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

68.86
/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 { AssetFile } from './files/AssetFile';
1✔
18
import type { BscFile } from './files/BscFile';
19
import type { BrsFile } from './files/BrsFile';
20
import { URI } from 'vscode-uri';
1✔
21
import { DiagnosticManager } from './DiagnosticManager';
1✔
22

23
/**
24
 * A runner class that handles
25
 */
26
export class ProgramBuilder {
1✔
27

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

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

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

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

68
    public diagnostics = new DiagnosticManager();
63✔
69

70
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
71
        if (!this.program) {
×
72
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
73
        }
74
        let file: BscFile | undefined = this.program.getFile(srcPath);
×
75
        if (!file) {
×
76
            // eslint-disable-next-line @typescript-eslint/dot-notation
NEW
77
            const paths = this.program['getPaths'](srcPath, this.program.options.rootDir ?? this.options.rootDir);
×
NEW
78
            file = new AssetFile(paths);
×
79
            //keep this for backwards-compatibility. TODO remove in v1
80
            // eslint-disable-next-line @typescript-eslint/dot-notation
NEW
81
            file['pathAbsolute'] = file.srcPath;
×
NEW
82
            diagnostic.file = file;
×
83
        }
84

85
        diagnostic.file = file;
×
NEW
86
        this.diagnostics.register(<any>diagnostic, { tags: ['ProgramBuilder'] });
×
87
    }
88

89
    public getDiagnostics() {
90
        return this.diagnostics.getDiagnostics();
127✔
91
    }
92

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

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

124
        this.createProgram();
48✔
125

126
        //parse every file in the entire project
127
        await this.loadFiles();
48✔
128
    }
129

130
    public async run(options: BsConfig) {
131
        this.logger.logLevel = options.logLevel as LogLevel;
48✔
132

133
        if (this.isRunning) {
48!
NEW
134
            throw new Error('Server is already running');
×
135
        }
136
        this.isRunning = true;
48✔
137

138
        await this.load(options);
48✔
139

140
        if (this.options.watch) {
48!
141
            this.logger.log('Starting compilation in watch mode...');
×
142
            await this.runOnce();
×
143
            this.enableWatchMode();
×
144
        } else {
145
            await this.runOnce();
48✔
146
        }
147
    }
148

149
    protected createProgram() {
150
        this.program = new Program(this.options, this.logger, this.plugins, this.diagnostics);
49✔
151

152
        this.plugins.emit('afterProgramCreate', {
49✔
153
            builder: this,
154
            program: this.program
155
        });
156

157
        return this.program;
49✔
158
    }
159

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

172
        this.plugins.emit('beforeProgramCreate', {
47✔
173
            builder: this
174
        });
175
    }
176

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

186
    private clearConsole() {
187
        if (this.allowConsoleClearing) {
46✔
188
            util.clearConsole();
8✔
189
        }
190
    }
191

192
    /**
193
     * A handle for the watch mode interval that keeps the process alive.
194
     * We need this so we can clear it if the builder is disposed
195
     */
196
    private watchInterval: NodeJS.Timer | undefined;
197

198
    public enableWatchMode() {
199
        this.watcher = new Watcher(this.options);
×
200
        if (this.watchInterval) {
×
201
            clearInterval(this.watchInterval);
×
202
        }
203
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
204
        this.watchInterval = setInterval(() => { }, 1073741824);
×
205

206
        //clear the console
207
        this.clearConsole();
×
208

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

211
        //add each set of files to the file watcher
212
        for (let fileObject of fileObjects) {
×
213
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
214
            this.watcher.watch(src);
×
215
        }
216

217
        this.logger.log('Watching for file changes...');
×
218

219
        let debouncedRunOnce = debounce(async () => {
×
220
            this.logger.log('File change detected. Starting incremental compilation...');
×
221
            await this.runOnce();
×
222
            this.logger.log(`Watching for file changes.`);
×
223
        }, 50);
224

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

253
    /**
254
     * The rootDir for this program.
255
     */
256
    public get rootDir() {
257
        if (!this.program) {
8!
258
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
259
        }
260
        return this.program.options.rootDir;
8✔
261
    }
262

263
    /**
264
     * A method that is used to cancel a previous run task.
265
     * Does nothing if previous run has completed or was already canceled
266
     */
267
    private cancelLastRun = () => {
63✔
268
        return Promise.resolve();
46✔
269
    };
270

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

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

292
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
293
        if (this.options?.showDiagnosticsInConsole === false) {
53!
294
            return;
37✔
295
        }
296
        if (!diagnostics) {
16✔
297
            diagnostics = this.getDiagnostics();
6✔
298
        }
299

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

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

313
        let srcPaths = Object.keys(diagnosticsByFile).sort();
16✔
314
        for (let srcPath of srcPaths) {
16✔
315
            let diagnosticsForFile = diagnosticsByFile[srcPath];
6✔
316
            //sort the diagnostics in line and column order
317
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
318
                return (
1✔
319
                    (a.range?.start.line ?? -1) - (b.range?.start.line ?? -1) ||
14!
320
                    (a.range?.start.character ?? -1) - (b.range?.start.character ?? -1)
12!
321
                );
322
            });
323

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

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

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

367
            //maybe cancel?
368
            if (cancellationToken.isCanceled === true) {
46!
369
                return -1;
×
370
            }
371

372
            const diagnostics = this.getDiagnostics();
46✔
373
            this.printDiagnostics(diagnostics);
46✔
374
            wereDiagnosticsPrinted = true;
46✔
375
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
46✔
376

377
            if (errorCount > 0) {
46✔
378
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
379
                return errorCount;
1✔
380
            }
381

382
            //create the deployment package (and transpile as well)
383
            await this.createPackageIfEnabled();
45✔
384

385
            //maybe cancel?
386
            if (cancellationToken.isCanceled === true) {
45!
387
                return -1;
×
388
            }
389

390
            //deploy the package
391
            await this.deployPackageIfEnabled();
45✔
392

393
            return 0;
45✔
394
        } catch (e) {
395
            if (wereDiagnosticsPrinted === false) {
×
396
                this.printDiagnostics();
×
397
            }
398
            throw e;
×
399
        }
400
    }
401

402
    private async createPackageIfEnabled() {
403
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
45✔
404

405
            //transpile the project
406
            await this.transpile();
6✔
407

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

422
    private buildThrottler = new Throttler(0);
63✔
423

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

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

440
            for (let fileEntry of fileMap) {
6✔
441
                if (this.program!.hasFile(fileEntry.src) === false) {
10!
UNCOV
442
                    filteredFileMap.push(fileEntry);
×
443
                }
444
            }
445

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

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

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

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

485
            const typedefFiles = [] as FileObj[];
50✔
486
            const allOtherFiles = [] as FileObj[];
50✔
487
            let manifestFile: FileObj | null = null;
50✔
488

489
            for (const file of files) {
50✔
490
                // typedef files
491
                if (/\.d\.bs$/i.test(file.dest)) {
36✔
492
                    typedefFiles.push(file);
2✔
493

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

504
            //load the manifest file first
505
            if (manifestFile) {
50✔
506
                this.program!.loadManifest(manifestFile, false);
7✔
507
            }
508

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

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

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

544
    public dispose() {
545
        if (this.watcher) {
22!
546
            this.watcher.dispose();
×
547
        }
548
        if (this.program) {
22!
549
            this.program.dispose?.();
22!
550
        }
551
        if (this.watchInterval) {
22!
552
            clearInterval(this.watchInterval);
×
553
        }
554
    }
555
}
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