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

rokucommunity / brighterscript / #13731

10 Feb 2025 06:43PM UTC coverage: 89.113% (+1.0%) from 88.149%
#13731

push

web-flow
Language Server Rewrite (#993)

7461 of 8823 branches covered (84.56%)

Branch coverage included in aggregate %.

1149 of 1263 new or added lines in 28 files covered. (90.97%)

23 existing lines in 5 files now uncovered.

9794 of 10540 relevant lines covered (92.92%)

1836.23 hits per line

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

72.51
/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();
107✔
31
        this.plugins = new PluginInterface([], { logger: this.logger });
107✔
32

33
        //add the default file resolver (used to load source file contents).
34
        this.addFileResolver((filePath) => {
107✔
35
            return fsExtra.readFile(filePath).then((value) => {
63✔
36
                return value.toString();
63✔
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;
107✔
44

45
    public options: FinalizedBsConfig = util.normalizeConfig({});
107✔
46
    private isRunning = false;
107✔
47
    private watcher: Watcher | undefined;
48
    public program: Program | undefined;
49
    public logger: Logger;
50
    public plugins: PluginInterface;
51
    private fileResolvers = [] as FileResolver[];
107✔
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);
107✔
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}`;
63✔
68
        let reversedResolvers = [...this.fileResolvers].reverse();
63✔
69
        for (let fileResolver of reversedResolvers) {
63✔
70
            let result = await fileResolver(srcPath);
68✔
71
            if (typeof result === 'string') {
68✔
72
                return result;
63✔
73
            }
74
        }
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[];
107✔
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: () => {
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 [
215✔
104
            ...this.staticDiagnostics,
105
            ...(this.program?.getDiagnostics() ?? [])
1,290!
106
        ];
107
    }
108

109
    public async run(options: BsConfig & {
110
        /**
111
         * Should validation run? Default is `true`. You must set exlicitly to `false` to disable.
112
         * @deprecated this is an experimental flag, and its behavior may change in a future release
113
         * @default true
114
         */
115
        validate?: boolean;
116
    }) {
117
        if (options?.logLevel) {
92✔
118
            this.logger.logLevel = options.logLevel;
2✔
119
        }
120

121
        if (this.isRunning) {
92!
122
            throw new Error('Server is already running');
×
123
        }
124
        this.isRunning = true;
92✔
125
        try {
92✔
126
            this.options = util.normalizeAndResolveConfig(options);
92✔
127
            if (this.options?.logLevel !== undefined) {
90!
128
                this.logger.logLevel = this.options?.logLevel;
90!
129
            }
130

131
            if (this.options.noProject) {
90!
132
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
133
            } else if (this.options.project) {
90✔
134
                this.logger.log(`Using config file: "${this.options.project}"`);
33✔
135
            } else {
136
                this.logger.log(`No bsconfig.json file found, using default options`);
57✔
137
            }
138
            this.loadRequires();
90✔
139
            this.loadPlugins();
90✔
140
        } catch (e: any) {
141
            //For now, just use a default options object so we have a functioning program
142
            this.options = util.normalizeConfig({
2✔
143
                showDiagnosticsInConsole: options?.showDiagnosticsInConsole
6!
144
            });
145

146
            if (e?.file && e.message && e.code) {
2!
147
                let err = e as BsDiagnostic;
2✔
148
                this.staticDiagnostics.push(err);
2✔
149
            } else {
150
                //if this is not a diagnostic, something else is wrong...
151
                throw e;
×
152
            }
153

154
            this.printDiagnostics();
2✔
155
        }
156
        this.logger.logLevel = this.options.logLevel;
92✔
157

158
        this.createProgram();
92✔
159

160
        //parse every file in the entire project
161
        await this.loadAllFilesAST();
92✔
162

163
        if (this.options.watch) {
92!
164
            this.logger.log('Starting compilation in watch mode...');
×
165
            await this.runOnce({
×
166
                validate: options?.validate
×
167
            });
168
            this.enableWatchMode();
×
169
        } else {
170
            await this.runOnce({
92✔
171
                validate: options?.validate
276✔
172
            });
173
        }
174
    }
175

176
    protected createProgram() {
177
        this.program = new Program(this.options, this.logger, this.plugins);
93✔
178

179
        this.plugins.emit('afterProgramCreate', this.program);
93✔
180

181
        return this.program;
93✔
182
    }
183

184
    protected loadPlugins() {
185
        const cwd = this.options.cwd ?? process.cwd();
90!
186
        const plugins = util.loadPlugins(
90✔
187
            cwd,
188
            this.options.plugins ?? [],
270!
189
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
190
        );
191
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins);
90!
192
        for (let plugin of plugins) {
90✔
193
            this.plugins.add(plugin);
1✔
194
        }
195

196
        this.plugins.emit('beforeProgramCreate', this);
90✔
197
    }
198

199
    /**
200
     * `require()` every options.require path
201
     */
202
    protected loadRequires() {
203
        for (const dep of this.options.require ?? []) {
90✔
204
            requireRelative(dep, this.options.cwd);
2✔
205
        }
206
    }
207

208
    private clearConsole() {
209
        if (this.allowConsoleClearing) {
89!
210
            util.clearConsole();
89✔
211
        }
212
    }
213

214
    /**
215
     * A handle for the watch mode interval that keeps the process alive.
216
     * We need this so we can clear it if the builder is disposed
217
     */
218
    private watchInterval: NodeJS.Timer | undefined;
219

220
    public enableWatchMode() {
221
        this.watcher = new Watcher(this.options);
×
222
        if (this.watchInterval) {
×
223
            clearInterval(this.watchInterval);
×
224
        }
225
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
226
        this.watchInterval = setInterval(() => { }, 1073741824);
×
227

228
        //clear the console
229
        this.clearConsole();
×
230

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

233
        //add each set of files to the file watcher
234
        for (let fileObject of fileObjects) {
×
235
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
236
            this.watcher.watch(src);
×
237
        }
238

239
        this.logger.log('Watching for file changes...');
×
240

241
        let debouncedRunOnce = debounce(async () => {
×
242
            this.logger.log('File change detected. Starting incremental compilation...');
×
243
            await this.runOnce();
×
244
            this.logger.log(`Watching for file changes.`);
×
245
        }, 50);
246

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

275
    /**
276
     * The rootDir for this program.
277
     */
278
    public get rootDir() {
UNCOV
279
        if (!this.program) {
×
280
            throw new Error('Cannot access `ProgramBuilder.rootDir` until after `ProgramBuilder.run()`');
×
281
        }
UNCOV
282
        return this.program.options.rootDir;
×
283
    }
284

285
    /**
286
     * A method that is used to cancel a previous run task.
287
     * Does nothing if previous run has completed or was already canceled
288
     */
289
    private cancelLastRun = () => {
107✔
290
        return Promise.resolve();
89✔
291
    };
292

293
    /**
294
     * Run the entire process exactly one time.
295
     */
296
    private runOnce(options?: { validate?: boolean }) {
297
        //clear the console
298
        this.clearConsole();
89✔
299
        let cancellationToken = { isCanceled: false };
89✔
300
        //wait for the previous run to complete
301
        let runPromise = this.cancelLastRun().then(() => {
89✔
302
            //start the new run
303
            return this._runOnce({
89✔
304
                cancellationToken: cancellationToken,
305
                validate: options?.validate
267!
306
            });
307
        }) as any;
308

309
        //a function used to cancel this run
310
        this.cancelLastRun = () => {
89✔
311
            cancellationToken.isCanceled = true;
×
312
            return runPromise;
×
313
        };
314
        return runPromise;
89✔
315
    }
316

317
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
318
        if (this.options?.showDiagnosticsInConsole === false) {
97!
319
            return;
83✔
320
        }
321
        if (!diagnostics) {
14✔
322
            diagnostics = this.getDiagnostics();
6✔
323
        }
324

325
        //group the diagnostics by file
326
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
14✔
327
        for (let diagnostic of diagnostics) {
14✔
328
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
7✔
329
                diagnosticsByFile[diagnostic.file.srcPath] = [];
6✔
330
            }
331
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
7✔
332
        }
333

334
        //get printing options
335
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
14✔
336
        const { cwd, emitFullPaths } = options;
14✔
337

338
        let srcPaths = Object.keys(diagnosticsByFile).sort();
14✔
339
        for (let srcPath of srcPaths) {
14✔
340
            let diagnosticsForFile = diagnosticsByFile[srcPath];
6✔
341
            //sort the diagnostics in line and column order
342
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
6✔
343
                return (
1✔
344
                    (a.range?.start.line ?? -1) - (b.range?.start.line ?? -1) ||
14!
345
                    (a.range?.start.character ?? -1) - (b.range?.start.character ?? -1)
12!
346
                );
347
            });
348

349
            let filePath = srcPath;
6✔
350
            if (!emitFullPaths) {
6!
351
                filePath = path.relative(cwd, filePath);
6✔
352
            }
353
            //load the file text
354
            const file = this.program?.getFile(srcPath);
6!
355
            //get the file's in-memory contents if available
356
            const lines = file?.fileContents?.split(/\r?\n/g) ?? [];
6✔
357

358
            for (let diagnostic of sortedDiagnostics) {
6✔
359
                //default the severity to error if undefined
360
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
7✔
361
                let relatedInformation = (diagnostic.relatedInformation ?? []).map(x => {
7!
362
                    let relatedInfoFilePath = URI.parse(x.location.uri).fsPath;
×
363
                    if (!emitFullPaths) {
×
364
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
365
                    }
366
                    return {
×
367
                        filePath: relatedInfoFilePath,
368
                        range: x.location.range,
369
                        message: x.message
370
                    };
371
                });
372
                //format output
373
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
7✔
374
            }
375
        }
376
    }
377

378
    /**
379
     * Run the process once, allowing it to be cancelled.
380
     * NOTE: This should only be called by `runOnce`.
381
     */
382
    private async _runOnce(options: { cancellationToken: { isCanceled: any }; validate: boolean }) {
383
        let wereDiagnosticsPrinted = false;
89✔
384
        try {
89✔
385
            //maybe cancel?
386
            if (options?.cancellationToken?.isCanceled === true) {
89!
387
                return -1;
×
388
            }
389
            //validate program. false means no, everything else (including missing) means true
390
            if (options?.validate !== false) {
89!
391
                this.validateProject();
7✔
392
            }
393

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

399
            const diagnostics = this.getDiagnostics();
89✔
400
            this.printDiagnostics(diagnostics);
89✔
401
            wereDiagnosticsPrinted = true;
89✔
402
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
89✔
403

404
            if (errorCount > 0) {
89✔
405
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
2!
406
                return errorCount;
2✔
407
            }
408

409
            //create the deployment package (and transpile as well)
410
            await this.createPackageIfEnabled();
87✔
411

412
            //maybe cancel?
413
            if (options?.cancellationToken?.isCanceled === true) {
87!
414
                return -1;
×
415
            }
416

417
            //deploy the package
418
            await this.deployPackageIfEnabled();
87✔
419

420
            return 0;
87✔
421
        } catch (e) {
422
            if (wereDiagnosticsPrinted === false) {
×
423
                this.printDiagnostics();
×
424
            }
425
            throw e;
×
426
        }
427
    }
428

429
    private async createPackageIfEnabled() {
430
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
87✔
431

432
            //transpile the project
433
            await this.transpile();
4✔
434

435
            //create the zip file if configured to do so
436
            if (this.options.createPackage !== false || this.options.deploy) {
4✔
437
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
438
                    await rokuDeploy.zipPackage({
2✔
439
                        ...this.options,
440
                        logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
441
                        outDir: util.getOutDir(this.options),
442
                        outFile: path.basename(this.options.outFile)
443
                    });
444
                });
445
            }
446
        }
447
    }
448

449
    private transpileThrottler = new Throttler(0);
107✔
450
    /**
451
     * Transpiles the entire program into the staging folder
452
     */
453
    public async transpile() {
454
        await this.transpileThrottler.run(async () => {
4✔
455
            let options = util.cwdWork(this.options.cwd, () => {
4✔
456
                return rokuDeploy.getOptions({
4✔
457
                    ...this.options,
458
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
459
                    outDir: util.getOutDir(this.options),
460
                    outFile: path.basename(this.options.outFile)
461

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

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

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

472
            for (let fileEntry of fileMap) {
4✔
473
                if (this.program!.hasFile(fileEntry.src) === false) {
4✔
474
                    filteredFileMap.push(fileEntry);
2✔
475
                }
476
            }
477

478
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
479

480
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
481
                //prepublish all non-program-loaded files to staging
482
                await rokuDeploy.prepublishToStaging({
4✔
483
                    ...options,
484
                    files: filteredFileMap
485
                });
486
            });
487

488
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
489
            this.plugins.emit('beforePublish', this, fileMap);
4✔
490

491
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
492
                //transpile any brighterscript files
493
                await this.program!.transpile(fileMap, options.stagingDir);
4✔
494
            });
495

496
            this.plugins.emit('afterPublish', this, fileMap);
4✔
497
        });
498
    }
499

500
    private async deployPackageIfEnabled() {
501
        //deploy the project if configured to do so
502
        if (this.options.deploy) {
87!
503
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
504
                await rokuDeploy.publish({
×
505
                    ...this.options,
506
                    logLevel: this.options.logLevel as unknown as RokuDeployLogLevel,
507
                    outDir: util.getOutDir(this.options),
508
                    outFile: path.basename(this.options.outFile)
509
                });
510
            });
511
        }
512
    }
513

514
    /**
515
     * Parse and load the AST for every file in the project
516
     */
517
    private async loadAllFilesAST() {
518
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
94✔
519
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
94✔
520
                return util.getFilePaths(this.options);
94✔
521
            });
522
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
94✔
523

524
            const typedefFiles = [] as FileObj[];
94✔
525
            const sourceFiles = [] as FileObj[];
94✔
526
            let manifestFile: FileObj | null = null;
94✔
527

528
            for (const file of files) {
94✔
529
                // source files (.brs, .bs, .xml)
530
                if (/(?<!\.d)\.(bs|brs|xml)$/i.test(file.dest)) {
77✔
531
                    sourceFiles.push(file);
67✔
532

533
                    // typedef files (.d.bs)
534
                } else if (/\.d\.bs$/i.test(file.dest)) {
10✔
535
                    typedefFiles.push(file);
2✔
536

537
                    // manifest file
538
                } else if (/^manifest$/i.test(file.dest)) {
8✔
539
                    manifestFile = file;
7✔
540
                }
541
            }
542

543
            if (manifestFile) {
94✔
544
                this.program!.loadManifest(manifestFile, false);
7✔
545
            }
546

547
            const loadFile = async (fileObj) => {
94✔
548
                try {
69✔
549
                    this.program!.setFile(fileObj, await this.getFileContents(fileObj.src));
69✔
550
                } catch (e) {
551
                    this.logger.log(e); // log the error, but don't fail this process because the file might be fixable later
×
552
                }
553
            };
554
            await Promise.all(typedefFiles.map(loadFile)); // preload every type definition file, which eliminates duplicate file loading
94✔
555
            await Promise.all(sourceFiles.map(loadFile)); // parse source files
94✔
556
        });
557
    }
558

559
    /**
560
     * Remove all files from the program that are in the specified folder path
561
     * @param srcPath the path to the folder to remove
562
     */
563
    public removeFilesInFolder(srcPath: string): boolean {
NEW
564
        let removedSomeFiles = false;
×
UNCOV
565
        for (let filePath in this.program.files) {
×
566
            //if the file path starts with the parent path and the file path does not exactly match the folder path
567
            if (filePath.startsWith(srcPath) && filePath !== srcPath) {
×
568
                this.program.removeFile(filePath);
×
NEW
569
                removedSomeFiles = true;
×
570
            }
571
        }
NEW
572
        return removedSomeFiles;
×
573
    }
574

575
    /**
576
     * Scan every file and resolve all variable references.
577
     * If no errors were encountered, return true. Otherwise return false.
578
     */
579
    private validateProject() {
580
        this.program.validate();
6✔
581
    }
582

583
    public dispose() {
584
        if (this.watcher) {
101!
585
            this.watcher.dispose();
×
586
        }
587
        if (this.program) {
101!
588
            this.program.dispose?.();
101!
589
        }
590
        if (this.watchInterval) {
101!
591
            clearInterval(this.watchInterval);
×
592
        }
593
    }
594
}
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