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

rokucommunity / brighterscript / #13698

06 Feb 2025 03:31PM UTC coverage: 88.149% (-0.04%) from 88.185%
#13698

push

web-flow
Add `validate` flag to ProgramBuilder.run() (#1409)

6803 of 8172 branches covered (83.25%)

Branch coverage included in aggregate %.

8 of 9 new or added lines in 1 file covered. (88.89%)

1 existing line in 1 file now uncovered.

9026 of 9785 relevant lines covered (92.24%)

1869.01 hits per line

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

71.43
/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 { BscFile, 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 { URI } from 'vscode-uri';
1✔
18

19
/**
20
 * A runner class that handles
21
 */
22
export class ProgramBuilder {
1✔
23

24
    public constructor() {
25
        //add the default file resolver (used to load source file contents).
26
        this.addFileResolver((filePath) => {
61✔
27
            return fsExtra.readFile(filePath).then((value) => {
25✔
28
                return value.toString();
25✔
29
            });
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;
61✔
36

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

45
    public addFileResolver(fileResolver: FileResolver) {
46
        this.fileResolvers.push(fileResolver);
96✔
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}`;
25✔
56
        let reversedResolvers = [...this.fileResolvers].reverse();
25✔
57
        for (let fileResolver of reversedResolvers) {
25✔
58
            let result = await fileResolver(srcPath);
45✔
59
            if (typeof result === 'string') {
45✔
60
                return result;
25✔
61
            }
62
        }
63
        throw new Error(`Could not load file "${srcPath}"`);
×
64
    }
65

66
    /**
67
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
68
     */
69
    private staticDiagnostics = [] as BsDiagnostic[];
61✔
70

71
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
72
        if (!this.program) {
×
73
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
74
        }
75
        let file: BscFile | undefined = this.program.getFile(srcPath);
×
76
        if (!file) {
×
77
            file = {
×
78
                pkgPath: this.program.getPkgPath(srcPath),
79
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
80
                srcPath: srcPath,
81
                getDiagnostics: () => {
82
                    return [<any>diagnostic];
×
83
                }
84
            } as BscFile;
85
        }
86
        diagnostic.file = file;
×
87
        this.staticDiagnostics.push(<any>diagnostic);
×
88
    }
89

90
    public getDiagnostics() {
91
        return [
109✔
92
            ...this.staticDiagnostics,
93
            ...(this.program?.getDiagnostics() ?? [])
654!
94
        ];
95
    }
96

97
    public async run(options: BsConfig & {
98
        /**
99
         * Should validation run? Default is `true`. You must set exlicitly to `false` to disable.
100
         * @deprecated this is an experimental flag, and its behavior may change in a future release
101
         * @default true
102
         */
103
        validate?: boolean;
104
    }) {
105
        if (options?.logLevel) {
46✔
106
            this.logger.logLevel = options.logLevel;
2✔
107
        }
108

109
        if (this.isRunning) {
46!
110
            throw new Error('Server is already running');
×
111
        }
112
        this.isRunning = true;
46✔
113
        try {
46✔
114
            this.options = util.normalizeAndResolveConfig(options);
46✔
115
            (this.options as typeof options).validate ??= true;
45✔
116
            if (this.options.noProject) {
45!
117
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
118
            } else if (this.options.project) {
45✔
119
                this.logger.log(`Using config file: "${this.options.project}"`);
9✔
120
            } else {
121
                this.logger.log(`No bsconfig.json file found, using default options`);
36✔
122
            }
123
            this.loadRequires();
45✔
124
            this.loadPlugins();
45✔
125
        } catch (e: any) {
126
            if (e?.file && e.message && e.code) {
1!
127
                let err = e as BsDiagnostic;
1✔
128
                this.staticDiagnostics.push(err);
1✔
129
            } else {
130
                //if this is not a diagnostic, something else is wrong...
131
                throw e;
×
132
            }
133
            this.printDiagnostics();
1✔
134

135
            //we added diagnostics, so hopefully that draws attention to the underlying issues.
136
            //For now, just use a default options object so we have a functioning program
137
            this.options = util.normalizeConfig({});
1✔
138
        }
139
        this.logger.logLevel = this.options.logLevel;
46✔
140

141
        this.createProgram();
46✔
142

143
        //parse every file in the entire project
144
        await this.loadAllFilesAST();
46✔
145

146
        if (this.options.watch) {
46!
147
            this.logger.log('Starting compilation in watch mode...');
×
NEW
148
            await this.runOnce({
×
149
                validate: options?.validate
×
150
            });
UNCOV
151
            this.enableWatchMode();
×
152
        } else {
153
            await this.runOnce({
46✔
154
                validate: options?.validate
138✔
155
            });
156
        }
157
    }
158

159
    protected createProgram() {
160
        this.program = new Program(this.options, this.logger, this.plugins);
47✔
161

162
        this.plugins.emit('afterProgramCreate', this.program);
47✔
163

164
        return this.program;
47✔
165
    }
166

167
    protected loadPlugins() {
168
        const cwd = this.options.cwd ?? process.cwd();
45!
169
        const plugins = util.loadPlugins(
45✔
170
            cwd,
171
            this.options.plugins ?? [],
135!
172
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
173
        );
174
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`);
45!
175
        for (let plugin of plugins) {
45✔
176
            this.plugins.add(plugin);
×
177
        }
178

179
        this.plugins.emit('beforeProgramCreate', this);
45✔
180
    }
181

182
    /**
183
     * `require()` every options.require path
184
     */
185
    protected loadRequires() {
186
        for (const dep of this.options.require ?? []) {
45✔
187
            requireRelative(dep, this.options.cwd);
2✔
188
        }
189
    }
190

191
    private clearConsole() {
192
        if (this.allowConsoleClearing) {
43✔
193
            util.clearConsole();
8✔
194
        }
195
    }
196

197
    /**
198
     * A handle for the watch mode interval that keeps the process alive.
199
     * We need this so we can clear it if the builder is disposed
200
     */
201
    private watchInterval: NodeJS.Timer | undefined;
202

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

211
        //clear the console
212
        this.clearConsole();
×
213

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

216
        //add each set of files to the file watcher
217
        for (let fileObject of fileObjects) {
×
218
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
219
            this.watcher.watch(src);
×
220
        }
221

222
        this.logger.log('Watching for file changes...');
×
223

224
        let debouncedRunOnce = debounce(async () => {
×
225
            this.logger.log('File change detected. Starting incremental compilation...');
×
226
            await this.runOnce();
×
227
            this.logger.log(`Watching for file changes.`);
×
228
        }, 50);
229

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

258
    /**
259
     * The rootDir for this program.
260
     */
261
    public get rootDir() {
262
        if (!this.program) {
8!
263
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
264
        }
265
        return this.program.options.rootDir;
8✔
266
    }
267

268
    /**
269
     * A method that is used to cancel a previous run task.
270
     * Does nothing if previous run has completed or was already canceled
271
     */
272
    private cancelLastRun = () => {
61✔
273
        return Promise.resolve();
43✔
274
    };
275

276
    /**
277
     * Run the entire process exactly one time.
278
     */
279
    private runOnce(options?: { validate?: boolean }) {
280
        //clear the console
281
        this.clearConsole();
43✔
282
        let cancellationToken = { isCanceled: false };
43✔
283
        //wait for the previous run to complete
284
        let runPromise = this.cancelLastRun().then(() => {
43✔
285
            //start the new run
286
            return this._runOnce({
43✔
287
                cancellationToken: cancellationToken,
288
                validate: options?.validate
129!
289
            });
290
        }) as any;
291

292
        //a function used to cancel this run
293
        this.cancelLastRun = () => {
43✔
294
            cancellationToken.isCanceled = true;
×
295
            return runPromise;
×
296
        };
297
        return runPromise;
43✔
298
    }
299

300
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
301
        if (this.options?.showDiagnosticsInConsole === false) {
50!
302
            return;
34✔
303
        }
304
        if (!diagnostics) {
16✔
305
            diagnostics = this.getDiagnostics();
6✔
306
        }
307

308
        //group the diagnostics by file
309
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
16✔
310
        for (let diagnostic of diagnostics) {
16✔
311
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
7✔
312
                diagnosticsByFile[diagnostic.file.srcPath] = [];
6✔
313
            }
314
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
7✔
315
        }
316

317
        //get printing options
318
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
16✔
319
        const { cwd, emitFullPaths } = options;
16✔
320

321
        let srcPaths = Object.keys(diagnosticsByFile).sort();
16✔
322
        for (let srcPath of srcPaths) {
16✔
323
            let diagnosticsForFile = diagnosticsByFile[srcPath];
6✔
324
            //sort the diagnostics in line and column order
325
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
326
                return (
1✔
327
                    (a.range?.start.line ?? -1) - (b.range?.start.line ?? -1) ||
14!
328
                    (a.range?.start.character ?? -1) - (b.range?.start.character ?? -1)
12!
329
                );
330
            });
331

332
            let filePath = srcPath;
6✔
333
            if (!emitFullPaths) {
6!
334
                filePath = path.relative(cwd, filePath);
6✔
335
            }
336
            //load the file text
337
            const file = this.program?.getFile(srcPath);
6!
338
            //get the file's in-memory contents if available
339
            const lines = file?.fileContents?.split(/\r?\n/g) ?? [];
6✔
340

341
            for (let diagnostic of sortedDiagnostics) {
6✔
342
                //default the severity to error if undefined
343
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
7✔
344
                let relatedInformation = (diagnostic.relatedInformation ?? []).map(x => {
7!
345
                    let relatedInfoFilePath = URI.parse(x.location.uri).fsPath;
×
346
                    if (!emitFullPaths) {
×
347
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
348
                    }
349
                    return {
×
350
                        filePath: relatedInfoFilePath,
351
                        range: x.location.range,
352
                        message: x.message
353
                    };
354
                });
355
                //format output
356
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
7✔
357
            }
358
        }
359
    }
360

361
    /**
362
     * Run the process once, allowing cancelability.
363
     * NOTE: This should only be called by `runOnce`.
364
     */
365
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; validate: boolean }) {
366
        let wereDiagnosticsPrinted = false;
43✔
367
        try {
43✔
368
            //maybe cancel?
369
            if (options?.cancellationToken?.isCanceled === true) {
43!
370
                return -1;
×
371
            }
372
            //validate program. false means no, everything else (including missing) means true
373
            if (options?.validate !== false) {
43!
374
                this.validateProject();
42✔
375
            }
376

377
            //maybe cancel?
378
            if (options?.cancellationToken?.isCanceled === true) {
43!
379
                return -1;
×
380
            }
381

382
            const diagnostics = this.getDiagnostics();
43✔
383
            this.printDiagnostics(diagnostics);
43✔
384
            wereDiagnosticsPrinted = true;
43✔
385
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
43✔
386

387
            if (errorCount > 0) {
43✔
388
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
389
                return errorCount;
1✔
390
            }
391

392
            //create the deployment package (and transpile as well)
393
            await this.createPackageIfEnabled();
42✔
394

395
            //maybe cancel?
396
            if (options?.cancellationToken?.isCanceled === true) {
42!
397
                return -1;
×
398
            }
399

400
            //deploy the package
401
            await this.deployPackageIfEnabled();
42✔
402

403
            return 0;
42✔
404
        } catch (e) {
405
            if (wereDiagnosticsPrinted === false) {
×
406
                this.printDiagnostics();
×
407
            }
408
            throw e;
×
409
        }
410
    }
411

412
    private async createPackageIfEnabled() {
413
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
42✔
414

415
            //transpile the project
416
            await this.transpile();
4✔
417

418
            //create the zip file if configured to do so
419
            if (this.options.createPackage !== false || this.options.deploy) {
4✔
420
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
421
                    await rokuDeploy.zipPackage({
2✔
422
                        ...this.options,
423
                        logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
424
                        outDir: util.getOutDir(this.options),
425
                        outFile: path.basename(this.options.outFile)
426
                    });
427
                });
428
            }
429
        }
430
    }
431

432
    private transpileThrottler = new Throttler(0);
61✔
433
    /**
434
     * Transpiles the entire program into the staging folder
435
     */
436
    public async transpile() {
437
        await this.transpileThrottler.run(async () => {
4✔
438
            let options = util.cwdWork(this.options.cwd, () => {
4✔
439
                return rokuDeploy.getOptions({
4✔
440
                    ...this.options,
441
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
442
                    outDir: util.getOutDir(this.options),
443
                    outFile: path.basename(this.options.outFile)
444

445
                    //rokuDeploy's return type says all its fields can be nullable, but it sets values for all of them.
446
                }) as any as Required<ReturnType<typeof rokuDeploy.getOptions>>;
447
            });
448

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

452
            //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
453
            let filteredFileMap = [] as FileObj[];
4✔
454

455
            for (let fileEntry of fileMap) {
4✔
456
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
457
                    filteredFileMap.push(fileEntry);
2✔
458
                }
459
            }
460

461
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
462

463
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
464
                //prepublish all non-program-loaded files to staging
465
                await rokuDeploy.prepublishToStaging({
4✔
466
                    ...options,
467
                    files: filteredFileMap
468
                });
469
            });
470

471
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
472
            this.plugins.emit('beforePublish', this, fileMap);
4✔
473

474
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
475
                //transpile any brighterscript files
476
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
477
            });
478

479
            this.plugins.emit('afterPublish', this, fileMap);
4✔
480
        });
481
    }
482

483
    private async deployPackageIfEnabled() {
484
        //deploy the project if configured to do so
485
        if (this.options.deploy) {
42!
486
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
487
                await rokuDeploy.publish({
×
488
                    ...this.options,
489
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
490
                    outDir: util.getOutDir(this.options),
491
                    outFile: path.basename(this.options.outFile)
492
                });
493
            });
494
        }
495
    }
496

497
    /**
498
     * Parse and load the AST for every file in the project
499
     */
500
    private async loadAllFilesAST() {
501
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
48✔
502
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
48✔
503
                return util.getFilePaths(this.options);
48✔
504
            });
505
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
48✔
506

507
            const typedefFiles = [] as FileObj[];
48✔
508
            const sourceFiles = [] as FileObj[];
48✔
509
            let manifestFile: FileObj | null = null;
48✔
510

511
            for (const file of files) {
48✔
512
                // source files (.brs, .bs, .xml)
513
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
32✔
514
                    sourceFiles.push(file);
25✔
515

516
                    // typedef files (.d.bs)
517
                } else if (/\.d\.bs$/i.test(file.dest)) {
7✔
518
                    typedefFiles.push(file);
2✔
519

520
                    // manifest file
521
                } else if (/^manifest$/i.test(file.dest)) {
5!
522
                    manifestFile = file;
5✔
523
                }
524
            }
525

526
            if (manifestFile) {
48✔
527
                this.program!.loadManifest(manifestFile, false);
5✔
528
            }
529

530
            const loadFile = async (fileObj) => {
48✔
531
                try {
27✔
532
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
27✔
533
                } catch (e) {
534
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
535
                }
536
            };
537
            await Promise.all(typedefFiles.map(loadFile)); // preload every type definition file, which eliminates duplicate file loading
48✔
538
            await Promise.all(sourceFiles.map(loadFile)); // parse source files
48✔
539
        });
540
    }
541

542
    /**
543
     * Remove all files from the program that are in the specified folder path
544
     * @param srcPath the path to the
545
     */
546
    public removeFilesInFolder(srcPath: string) {
547
        for (let filePath in this.program.files) {
×
548
            //if the file path starts with the parent path and the file path does not exactly match the folder path
549
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
550
                this.program.removeFile(filePath);
×
551
            }
552
        }
553
    }
554

555
    /**
556
     * Scan every file and resolve all variable references.
557
     * If no errors were encountered, return true. Otherwise return false.
558
     */
559
    private validateProject() {
560
        this.program.validate();
41✔
561
    }
562

563
    public dispose() {
564
        if (this.watcher) {
23!
565
            this.watcher.dispose();
×
566
        }
567
        if (this.program) {
23!
568
            this.program.dispose?.();
23!
569
        }
570
        if (this.watchInterval) {
23!
571
            clearInterval(this.watchInterval);
×
572
        }
573
    }
574
}
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