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

rokucommunity / brighterscript / #13836

13 Jun 2024 02:54AM UTC coverage: 88.73% (+1.3%) from 87.41%
#13836

push

TwitchBronBron
Finally fixed the completions bug

6560 of 7851 branches covered (83.56%)

Branch coverage included in aggregate %.

14 of 16 new or added lines in 4 files covered. (87.5%)

364 existing lines in 17 files now uncovered.

9430 of 10170 relevant lines covered (92.72%)

1667.59 hits per line

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

72.3
/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 type { Logger } from './logging';
12
import { LogLevel, createLogger } from './logging';
1✔
13
import PluginInterface from './PluginInterface';
1✔
14
import * as diagnosticUtils from './diagnosticUtils';
1✔
15
import * as fsExtra from 'fs-extra';
1✔
16
import * as requireRelative from 'require-relative';
1✔
17
import { Throttler } from './Throttler';
1✔
18
import { URI } from 'vscode-uri';
1✔
19

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

25
    public constructor(
26
        options?: {
27
            logger?: Logger;
28
        }
29
    ) {
30
        this.logger = options?.logger ?? createLogger();
89✔
31
        this.plugins = new PluginInterface([], { logger: this.logger });
89✔
32

33
        //add the default file resolver (used to load source file contents).
34
        this.addFileResolver((filePath) => {
89✔
35
            return fsExtra.readFile(filePath).then((value) => {
34✔
36
                return value.toString();
34✔
37
            });
38
        });
39
    }
40
    /**
41
     * Determines whether the console should be cleared after a run (true for cli, false for languageserver)
42
     */
43
    public allowConsoleClearing = true;
89✔
44

45
    public options: FinalizedBsConfig = util.normalizeConfig({});
89✔
46
    private isRunning = false;
89✔
47
    private watcher: Watcher | undefined;
48
    public program: Program | undefined;
49
    public logger: Logger;
50
    public plugins: PluginInterface;
51
    private fileResolvers = [] as FileResolver[];
89✔
52

53
    /**
54
     * Add file resolvers that will be able to provide file contents before loading from the file system
55
     * @param fileResolvers a list of file resolvers
56
     */
57
    public addFileResolver(...fileResolvers: FileResolver[]) {
58
        this.fileResolvers.push(...fileResolvers);
89✔
59
    }
60

61
    /**
62
     * Get the contents of the specified file as a string.
63
     * This walks backwards through the file resolvers until we get a value.
64
     * This allow the language server to provide file contents directly from memory.
65
     */
66
    public async getFileContents(srcPath: string) {
67
        srcPath = s`${srcPath}`;
34✔
68
        let reversedResolvers = [...this.fileResolvers].reverse();
34✔
69
        for (let fileResolver of reversedResolvers) {
34✔
70
            let result = await fileResolver(srcPath);
39✔
71
            if (typeof result === 'string') {
39✔
72
                return result;
34✔
73
            }
74
        }
UNCOV
75
        throw new Error(`Could not load file "${srcPath}"`);
×
76
    }
77

78
    /**
79
     * A list of diagnostics that are always added to the `getDiagnostics()` call.
80
     */
81
    private staticDiagnostics = [] as BsDiagnostic[];
89✔
82

83
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
84
        if (!this.program) {
1!
85
            throw new Error('Cannot call `ProgramBuilder.addDiagnostic` before `ProgramBuilder.run()`');
×
86
        }
87
        let file: BscFile | undefined = this.program.getFile(srcPath);
1✔
88
        if (!file) {
1!
89
            file = {
1✔
90
                pkgPath: path.basename(srcPath),
91
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
92
                srcPath: srcPath,
93
                getDiagnostics: () => {
UNCOV
94
                    return [<any>diagnostic];
×
95
                }
96
            } as BscFile;
97
        }
98
        diagnostic.file = file;
1✔
99
        this.staticDiagnostics.push(<any>diagnostic);
1✔
100
    }
101

102
    public getDiagnostics() {
103
        return [
157✔
104
            ...this.staticDiagnostics,
105
            ...(this.program?.getDiagnostics() ?? [])
942!
106
        ];
107
    }
108

109
    public async run(options: BsConfig & { skipInitialValidation?: boolean }) {
110
        this.logger.logLevel = options.logLevel;
74✔
111

112
        if (this.isRunning) {
74!
UNCOV
113
            throw new Error('Server is already running');
×
114
        }
115
        this.isRunning = true;
74✔
116
        try {
74✔
117
            this.options = util.normalizeAndResolveConfig(options);
74✔
118
            if (this.options?.logLevel !== undefined) {
73!
119
                this.logger.logLevel = this.options?.logLevel;
73!
120
            }
121

122
            if (this.options.noProject) {
73!
UNCOV
123
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
124
            } else if (this.options.project) {
73✔
125
                this.logger.log(`Using config file: "${this.options.project}"`);
26✔
126
            } else {
127
                this.logger.log(`No bsconfig.json file found, using default options`);
47✔
128
            }
129
            this.loadRequires();
73✔
130
            this.loadPlugins();
73✔
131
        } catch (e: any) {
132
            if (e?.file && e.message && e.code) {
1!
133
                let err = e as BsDiagnostic;
1✔
134
                this.staticDiagnostics.push(err);
1✔
135
            } else {
136
                //if this is not a diagnostic, something else is wrong...
UNCOV
137
                throw e;
×
138
            }
139
            this.printDiagnostics();
1✔
140

141
            //we added diagnostics, so hopefully that draws attention to the underlying issues.
142
            //For now, just use a default options object so we have a functioning program
143
            this.options = util.normalizeConfig({});
1✔
144
        }
145
        this.logger.logLevel = this.options.logLevel;
74✔
146

147
        this.createProgram();
74✔
148

149
        //parse every file in the entire project
150
        await this.loadAllFilesAST();
74✔
151

152
        if (this.options.watch) {
74!
UNCOV
153
            this.logger.log('Starting compilation in watch mode...');
×
UNCOV
154
            await this.runOnce({
×
155
                skipValidation: options.skipInitialValidation
156
            });
UNCOV
157
            this.enableWatchMode();
×
158
        } else {
159
            await this.runOnce({
74✔
160
                skipValidation: options.skipInitialValidation
161
            });
162
        }
163
    }
164

165
    protected createProgram() {
166
        this.program = new Program(this.options, this.logger, this.plugins);
75✔
167

168
        this.plugins.emit('afterProgramCreate', this.program);
75✔
169

170
        return this.program;
75✔
171
    }
172

173
    protected loadPlugins() {
174
        const cwd = this.options.cwd ?? process.cwd();
73!
175
        const plugins = util.loadPlugins(
73✔
176
            cwd,
177
            this.options.plugins ?? [],
219!
UNCOV
178
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
179
        );
180
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins);
73!
181
        for (let plugin of plugins) {
73✔
182
            this.plugins.add(plugin);
1✔
183
        }
184

185
        this.plugins.emit('beforeProgramCreate', this);
73✔
186
    }
187

188
    /**
189
     * `require()` every options.require path
190
     */
191
    protected loadRequires() {
192
        for (const dep of this.options.require ?? []) {
73✔
193
            requireRelative(dep, this.options.cwd);
2✔
194
        }
195
    }
196

197
    private clearConsole() {
198
        if (this.allowConsoleClearing) {
72!
199
            util.clearConsole();
72✔
200
        }
201
    }
202

203
    /**
204
     * A handle for the watch mode interval that keeps the process alive.
205
     * We need this so we can clear it if the builder is disposed
206
     */
207
    private watchInterval: NodeJS.Timer | undefined;
208

209
    public enableWatchMode() {
210
        this.watcher = new Watcher(this.options);
×
211
        if (this.watchInterval) {
×
212
            clearInterval(this.watchInterval);
×
213
        }
214
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
215
        this.watchInterval = setInterval(() => { }, 1073741824);
×
216

217
        //clear the console
218
        this.clearConsole();
×
219

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

222
        //add each set of files to the file watcher
UNCOV
223
        for (let fileObject of fileObjects) {
×
224
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
225
            this.watcher.watch(src);
×
226
        }
227

228
        this.logger.log('Watching for file changes...');
×
229

230
        let debouncedRunOnce = debounce(async () => {
×
UNCOV
231
            this.logger.log('File change detected. Starting incremental compilation...');
×
UNCOV
232
            await this.runOnce();
×
UNCOV
233
            this.logger.log(`Watching for file changes.`);
×
234
        }, 50);
235

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

264
    /**
265
     * The rootDir for this program.
266
     */
267
    public get rootDir() {
UNCOV
268
        if (!this.program) {
×
UNCOV
269
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
270
        }
UNCOV
271
        return this.program.options.rootDir;
×
272
    }
273

274
    /**
275
     * A method that is used to cancel a previous run task.
276
     * Does nothing if previous run has completed or was already canceled
277
     */
278
    private cancelLastRun = () => {
89✔
279
        return Promise.resolve();
72✔
280
    };
281

282
    /**
283
     * Run the entire process exactly one time.
284
     */
285
    private runOnce(options?: { skipValidation?: boolean }) {
286
        //clear the console
287
        this.clearConsole();
72✔
288
        let cancellationToken = { isCanceled: false };
72✔
289
        //wait for the previous run to complete
290
        let runPromise = this.cancelLastRun().then(() => {
72✔
291
            //start the new run
292
            return this._runOnce({
72✔
293
                cancellationToken: cancellationToken,
294
                skipValidation: options?.skipValidation
216!
295
            });
296
        }) as any;
297

298
        //a function used to cancel this run
299
        this.cancelLastRun = () => {
72✔
UNCOV
300
            cancellationToken.isCanceled = true;
×
UNCOV
301
            return runPromise;
×
302
        };
303
        return runPromise;
72✔
304
    }
305

306
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
307
        if (this.options?.showDiagnosticsInConsole === false) {
79!
308
            return;
67✔
309
        }
310
        if (!diagnostics) {
12✔
311
            diagnostics = this.getDiagnostics();
6✔
312
        }
313

314
        //group the diagnostics by file
315
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
12✔
316
        for (let diagnostic of diagnostics) {
12✔
317
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
7✔
318
                diagnosticsByFile[diagnostic.file.srcPath] = [];
6✔
319
            }
320
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
7✔
321
        }
322

323
        //get printing options
324
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
12✔
325
        const { cwd, emitFullPaths } = options;
12✔
326

327
        let srcPaths = Object.keys(diagnosticsByFile).sort();
12✔
328
        for (let srcPath of srcPaths) {
12✔
329
            let diagnosticsForFile = diagnosticsByFile[srcPath];
6✔
330
            //sort the diagnostics in line and column order
331
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
332
                return (
1✔
333
                    (a.range?.start.line ?? -1) - (b.range?.start.line ?? -1) ||
14!
334
                    (a.range?.start.character ?? -1) - (b.range?.start.character ?? -1)
12!
335
                );
336
            });
337

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

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

367
    /**
368
     * Run the process once, allowing cancelability.
369
     * NOTE: This should only be called by `runOnce`.
370
     */
371
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; skipValidation: boolean }) {
372
        let wereDiagnosticsPrinted = false;
72✔
373
        try {
72✔
374
            //maybe cancel?
375
            if (options.cancellationToken.isCanceled === true) {
72!
UNCOV
376
                return -1;
×
377
            }
378
            //validate program
379
            if (options.skipValidation !== true) {
72✔
380
                this.validateProject();
6✔
381
            }
382

383
            //maybe cancel?
384
            if (options.cancellationToken.isCanceled === true) {
72!
UNCOV
385
                return -1;
×
386
            }
387

388
            const diagnostics = this.getDiagnostics();
72✔
389
            this.printDiagnostics(diagnostics);
72✔
390
            wereDiagnosticsPrinted = true;
72✔
391
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
72✔
392

393
            if (errorCount > 0) {
72✔
394
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
395
                return errorCount;
1✔
396
            }
397

398
            //create the deployment package (and transpile as well)
399
            await this.createPackageIfEnabled();
71✔
400

401
            //maybe cancel?
402
            if (options.cancellationToken.isCanceled === true) {
71!
UNCOV
403
                return -1;
×
404
            }
405

406
            //deploy the package
407
            await this.deployPackageIfEnabled();
71✔
408

409
            return 0;
71✔
410
        } catch (e) {
UNCOV
411
            if (wereDiagnosticsPrinted === false) {
×
UNCOV
412
                this.printDiagnostics();
×
413
            }
UNCOV
414
            throw e;
×
415
        }
416
    }
417

418
    private async createPackageIfEnabled() {
419
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
71✔
420

421
            //transpile the project
422
            await this.transpile();
4✔
423

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

438
    private transpileThrottler = new Throttler(0);
89✔
439
    /**
440
     * Transpiles the entire program into the staging folder
441
     */
442
    public async transpile() {
443
        await this.transpileThrottler.run(async () => {
4✔
444
            let options = util.cwdWork(this.options.cwd, () => {
4✔
445
                return rokuDeploy.getOptions({
4✔
446
                    ...this.options,
447
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
448
                    outDir: util.getOutDir(this.options),
449
                    outFile: path.basename(this.options.outFile)
450

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

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

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

461
            for (let fileEntry of fileMap) {
4✔
462
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
463
                    filteredFileMap.push(fileEntry);
2✔
464
                }
465
            }
466

467
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
468

469
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
470
                //prepublish all non-program-loaded files to staging
471
                await rokuDeploy.prepublishToStaging({
4✔
472
                    ...options,
473
                    files: filteredFileMap
474
                });
475
            });
476

477
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
478
            this.plugins.emit('beforePublish', this, fileMap);
4✔
479

480
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
481
                //transpile any brighterscript files
482
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
483
            });
484

485
            this.plugins.emit('afterPublish', this, fileMap);
4✔
486
        });
487
    }
488

489
    private async deployPackageIfEnabled() {
490
        //deploy the project if configured to do so
491
        if (this.options.deploy) {
71!
UNCOV
492
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
UNCOV
493
                await rokuDeploy.publish({
×
494
                    ...this.options,
495
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
496
                    outDir: util.getOutDir(this.options),
497
                    outFile: path.basename(this.options.outFile)
498
                });
499
            });
500
        }
501
    }
502

503
    /**
504
     * Parse and load the AST for every file in the project
505
     */
506
    private async loadAllFilesAST() {
507
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
76✔
508
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
76✔
509
                return util.getFilePaths(this.options);
76✔
510
            });
511
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
76✔
512

513
            const typedefFiles = [] as FileObj[];
76✔
514
            const sourceFiles = [] as FileObj[];
76✔
515
            let manifestFile: FileObj | null = null;
76✔
516

517
            for (const file of files) {
76✔
518
                // source files (.brs, .bs, .xml)
519
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
48✔
520
                    sourceFiles.push(file);
38✔
521

522
                    // typedef files (.d.bs)
523
                } else if (/\.d\.bs$/i.test(file.dest)) {
10✔
524
                    typedefFiles.push(file);
2✔
525

526
                    // manifest file
527
                } else if (/^manifest$/i.test(file.dest)) {
8✔
528
                    manifestFile = file;
7✔
529
                }
530
            }
531

532
            if (manifestFile) {
76✔
533
                this.program!.loadManifest(manifestFile, false);
7✔
534
            }
535

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

548
    /**
549
     * Remove all files from the program that are in the specified folder path
550
     * @param srcPath the path to the folder to remove
551
     */
552
    public removeFilesInFolder(srcPath: string): boolean {
UNCOV
553
        let removedSomeFiles = false;
×
UNCOV
554
        for (let filePath in this.program.files) {
×
555
            //if the file path starts with the parent path and the file path does not exactly match the folder path
UNCOV
556
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
UNCOV
557
                this.program.removeFile(filePath);
×
UNCOV
558
                removedSomeFiles = true;
×
559
            }
560
        }
561
        return removedSomeFiles;
×
562
    }
563

564
    /**
565
     * Scan every file and resolve all variable references.
566
     * If no errors were encountered, return true. Otherwise return false.
567
     */
568
    private validateProject() {
569
        this.program.validate();
6✔
570
    }
571

572
    public dispose() {
573
        if (this.watcher) {
83!
UNCOV
574
            this.watcher.dispose();
×
575
        }
576
        if (this.program) {
83!
577
            this.program.dispose?.();
83!
578
        }
579
        if (this.watchInterval) {
83!
UNCOV
580
            clearInterval(this.watchInterval);
×
581
        }
582
    }
583
}
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

© 2026 Coveralls, Inc