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

rokucommunity / brighterscript / #10687

pending completion
#10687

push

web-flow
Print diagnostic related information (#867)

* Fix tab issue when printing diagnostics

* include related info in printed diagnostics

* Fix "....and X more" loop

5587 of 6814 branches covered (81.99%)

Branch coverage included in aggregate %.

18 of 18 new or added lines in 2 files covered. (100.0%)

8499 of 9184 relevant lines covered (92.54%)

1619.51 hits per line

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

70.52
/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 { BsConfig } from './BsConfig';
5
import type { BscFile, BsDiagnostic, FileObj, FileResolver } from './interfaces';
6
import { Program } from './Program';
1✔
7
import { standardizePath as s, util } from './util';
1✔
8
import { Watcher } from './Watcher';
1✔
9
import { DiagnosticSeverity } from 'vscode-languageserver';
1✔
10
import { Logger, LogLevel } from './Logger';
1✔
11
import PluginInterface from './PluginInterface';
1✔
12
import * as diagnosticUtils from './diagnosticUtils';
1✔
13
import * as fsExtra from 'fs-extra';
1✔
14
import * as requireRelative from 'require-relative';
1✔
15
import { Throttler } from './Throttler';
1✔
16
import { URI } from 'vscode-uri';
1✔
17

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

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

36
    public options: BsConfig;
37
    private isRunning = false;
53✔
38
    private watcher: Watcher;
39
    public program: Program;
40
    public logger = new Logger();
53✔
41
    public plugins: PluginInterface = new PluginInterface([], { logger: this.logger });
53✔
42
    private fileResolvers = [] as FileResolver[];
53✔
43

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

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

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

70
    public addDiagnostic(srcPath: string, diagnostic: Partial<BsDiagnostic>) {
71
        let file: BscFile = this.program.getFile(srcPath);
×
72
        if (!file) {
×
73
            file = {
×
74
                pkgPath: this.program.getPkgPath(srcPath),
75
                pathAbsolute: srcPath, //keep this for backwards-compatibility. TODO remove in v1
76
                srcPath: srcPath,
77
                getDiagnostics: () => {
78
                    return [<any>diagnostic];
×
79
                }
80
            } as BscFile;
81
        }
82
        diagnostic.file = file;
×
83
        this.staticDiagnostics.push(<any>diagnostic);
×
84
    }
85

86
    public getDiagnostics() {
87
        return [
102✔
88
            ...this.staticDiagnostics,
89
            ...(this.program?.getDiagnostics() ?? [])
612!
90
        ];
91
    }
92

93
    public async run(options: BsConfig) {
94
        this.logger.logLevel = options.logLevel as LogLevel;
42✔
95

96
        if (this.isRunning) {
42!
97
            throw new Error('Server is already running');
×
98
        }
99
        this.isRunning = true;
42✔
100
        try {
42✔
101
            this.options = util.normalizeAndResolveConfig(options);
42✔
102
            if (this.options.project) {
41✔
103
                this.logger.log(`Using config file: "${this.options.project}"`);
9✔
104
            } else {
105
                this.logger.log(`No bsconfig.json file found, using default options`);
32✔
106
            }
107
            this.loadRequires();
41✔
108
            this.loadPlugins();
41✔
109
        } catch (e: any) {
110
            if (e?.file && e.message && e.code) {
1!
111
                let err = e as BsDiagnostic;
1✔
112
                this.staticDiagnostics.push(err);
1✔
113
            } else {
114
                //if this is not a diagnostic, something else is wrong...
115
                throw e;
×
116
            }
117
            this.printDiagnostics();
1✔
118

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

125
        this.program = this.createProgram();
42✔
126

127
        //parse every file in the entire project
128
        await this.loadAllFilesAST();
42✔
129

130
        if (this.options.watch) {
42!
131
            this.logger.log('Starting compilation in watch mode...');
×
132
            await this.runOnce();
×
133
            this.enableWatchMode();
×
134
        } else {
135
            await this.runOnce();
42✔
136
        }
137
    }
138

139
    protected createProgram() {
140
        const program = new Program(this.options, this.logger, this.plugins);
42✔
141

142
        this.plugins.emit('afterProgramCreate', program);
42✔
143
        return program;
42✔
144
    }
145

146
    protected loadPlugins() {
147
        const cwd = this.options.cwd ?? process.cwd();
41!
148
        const plugins = util.loadPlugins(
41✔
149
            cwd,
150
            this.options.plugins ?? [],
123!
151
            (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err)
×
152
        );
153
        this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`);
41!
154
        for (let plugin of plugins) {
41✔
155
            this.plugins.add(plugin);
×
156
        }
157

158
        this.plugins.emit('beforeProgramCreate', this);
41✔
159
    }
160

161
    /**
162
     * `require()` every options.require path
163
     */
164
    protected loadRequires() {
165
        for (const dep of this.options.require ?? []) {
41✔
166
            requireRelative(dep, this.options.cwd);
2✔
167
        }
168
    }
169

170
    private clearConsole() {
171
        if (this.allowConsoleClearing) {
40✔
172
            util.clearConsole();
6✔
173
        }
174
    }
175

176
    /**
177
     * A handle for the watch mode interval that keeps the process alive.
178
     * We need this so we can clear it if the builder is disposed
179
     */
180
    private watchInterval: NodeJS.Timer;
181

182
    public enableWatchMode() {
183
        this.watcher = new Watcher(this.options);
×
184
        if (this.watchInterval) {
×
185
            clearInterval(this.watchInterval);
×
186
        }
187
        //keep the process alive indefinitely by setting an interval that runs once every 12 days
188
        this.watchInterval = setInterval(() => { }, 1073741824);
×
189

190
        //clear the console
191
        this.clearConsole();
×
192

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

195
        //add each set of files to the file watcher
196
        for (let fileObject of fileObjects) {
×
197
            let src = typeof fileObject === 'string' ? fileObject : fileObject.src;
×
198
            this.watcher.watch(src);
×
199
        }
200

201
        this.logger.log('Watching for file changes...');
×
202

203
        let debouncedRunOnce = debounce(async () => {
×
204
            this.logger.log('File change detected. Starting incremental compilation...');
×
205
            await this.runOnce();
×
206
            this.logger.log(`Watching for file changes.`);
×
207
        }, 50);
208

209
        //on any file watcher event
210
        this.watcher.on('all', async (event: string, thePath: string) => { //eslint-disable-line @typescript-eslint/no-misused-promises
×
211
            thePath = s`${path.resolve(this.rootDir, thePath)}`;
×
212
            if (event === 'add' || event === 'change') {
×
213
                const fileObj = {
×
214
                    src: thePath,
215
                    dest: rokuDeploy.getDestPath(
216
                        thePath,
217
                        this.program.options.files,
218
                        //some shells will toTowerCase the drive letter, so do it to rootDir for consistency
219
                        util.driveLetterToLower(this.rootDir)
220
                    )
221
                };
222
                this.program.setFile(
×
223
                    fileObj,
224
                    await this.getFileContents(fileObj.src)
225
                );
226
            } else if (event === 'unlink') {
×
227
                this.program.removeFile(thePath);
×
228
            }
229
            //wait for change events to settle, and then execute `run`
230
            await debouncedRunOnce();
×
231
        });
232
    }
233

234
    /**
235
     * The rootDir for this program.
236
     */
237
    public get rootDir() {
238
        return this.program.options.rootDir;
8✔
239
    }
240

241
    /**
242
     * A method that is used to cancel a previous run task.
243
     * Does nothing if previous run has completed or was already canceled
244
     */
245
    private cancelLastRun = () => {
53✔
246
        return Promise.resolve();
40✔
247
    };
248

249
    /**
250
     * Run the entire process exactly one time.
251
     */
252
    private runOnce() {
253
        //clear the console
254
        this.clearConsole();
40✔
255
        let cancellationToken = { isCanceled: false };
40✔
256
        //wait for the previous run to complete
257
        let runPromise = this.cancelLastRun().then(() => {
40✔
258
            //start the new run
259
            return this._runOnce(cancellationToken);
40✔
260
        }) as any;
261

262
        //a function used to cancel this run
263
        this.cancelLastRun = () => {
40✔
264
            cancellationToken.isCanceled = true;
×
265
            return runPromise;
×
266
        };
267
        return runPromise;
40✔
268
    }
269

270
    private printDiagnostics(diagnostics?: BsDiagnostic[]) {
271
        if (this.options?.showDiagnosticsInConsole === false) {
46!
272
            return;
33✔
273
        }
274
        if (!diagnostics) {
13✔
275
            diagnostics = this.getDiagnostics();
5✔
276
        }
277

278
        //group the diagnostics by file
279
        let diagnosticsByFile = {} as Record<string, BsDiagnostic[]>;
13✔
280
        for (let diagnostic of diagnostics) {
13✔
281
            if (!diagnosticsByFile[diagnostic.file.srcPath]) {
5!
282
                diagnosticsByFile[diagnostic.file.srcPath] = [];
5✔
283
            }
284
            diagnosticsByFile[diagnostic.file.srcPath].push(diagnostic);
5✔
285
        }
286

287
        //get printing options
288
        const options = diagnosticUtils.getPrintDiagnosticOptions(this.options);
13✔
289
        const { cwd, emitFullPaths } = options;
13✔
290

291
        let srcPaths = Object.keys(diagnosticsByFile).sort();
13✔
292
        for (let srcPath of srcPaths) {
13✔
293
            let diagnosticsForFile = diagnosticsByFile[srcPath];
5✔
294
            //sort the diagnostics in line and column order
295
            let sortedDiagnostics = diagnosticsForFile.sort((a, b) => {
5✔
296
                return (
×
297
                    a.range.start.line - b.range.start.line ||
×
298
                    a.range.start.character - b.range.start.character
299
                );
300
            });
301

302
            let filePath = srcPath;
5✔
303
            if (!emitFullPaths) {
5!
304
                filePath = path.relative(cwd, filePath);
5✔
305
            }
306
            //load the file text
307
            const file = this.program?.getFile(srcPath);
5!
308
            //get the file's in-memory contents if available
309
            const lines = file?.fileContents?.split(/\r?\n/g) ?? [];
5✔
310

311
            for (let diagnostic of sortedDiagnostics) {
5✔
312
                //default the severity to error if undefined
313
                let severity = typeof diagnostic.severity === 'number' ? diagnostic.severity : DiagnosticSeverity.Error;
5!
314
                let relatedInformation = (diagnostic.relatedInformation ?? []).map(x => {
5!
315
                    let relatedInfoFilePath = URI.parse(x.location.uri).fsPath;
×
316
                    if (!emitFullPaths) {
×
317
                        relatedInfoFilePath = path.relative(cwd, relatedInfoFilePath);
×
318
                    }
319
                    return {
×
320
                        filePath: relatedInfoFilePath,
321
                        range: x.location.range,
322
                        message: x.message
323
                    };
324
                });
325
                //format output
326
                diagnosticUtils.printDiagnostic(options, severity, filePath, lines, diagnostic, relatedInformation);
5✔
327
            }
328
        }
329
    }
330

331
    /**
332
     * Run the process once, allowing cancelability.
333
     * NOTE: This should only be called by `runOnce`.
334
     */
335
    private async _runOnce(cancellationToken: { isCanceled: any }) {
336
        let wereDiagnosticsPrinted = false;
40✔
337
        try {
40✔
338
            //maybe cancel?
339
            if (cancellationToken.isCanceled === true) {
40!
340
                return -1;
×
341
            }
342
            //validate program
343
            this.validateProject();
40✔
344

345
            //maybe cancel?
346
            if (cancellationToken.isCanceled === true) {
40!
347
                return -1;
×
348
            }
349

350
            const diagnostics = this.getDiagnostics();
40✔
351
            this.printDiagnostics(diagnostics);
40✔
352
            wereDiagnosticsPrinted = true;
40✔
353
            let errorCount = diagnostics.filter(x => x.severity === DiagnosticSeverity.Error).length;
40✔
354

355
            if (errorCount > 0) {
40✔
356
                this.logger.log(`Found ${errorCount} ${errorCount === 1 ? 'error' : 'errors'}`);
1!
357
                return errorCount;
1✔
358
            }
359

360
            //create the deployment package (and transpile as well)
361
            await this.createPackageIfEnabled();
39✔
362

363
            //maybe cancel?
364
            if (cancellationToken.isCanceled === true) {
39!
365
                return -1;
×
366
            }
367

368
            //deploy the package
369
            await this.deployPackageIfEnabled();
39✔
370

371
            return 0;
39✔
372
        } catch (e) {
373
            if (wereDiagnosticsPrinted === false) {
×
374
                this.printDiagnostics();
×
375
            }
376
            throw e;
×
377
        }
378
    }
379

380
    private async createPackageIfEnabled() {
381
        if (this.options.copyToStaging || this.options.createPackage || this.options.deploy) {
39✔
382

383
            //transpile the project
384
            await this.transpile();
4✔
385

386
            //create the zip file if configured to do so
387
            if (this.options.createPackage !== false || this.options.deploy) {
4✔
388
                await this.logger.time(LogLevel.log, [`Creating package at ${this.options.outFile}`], async () => {
2✔
389
                    await rokuDeploy.zipPackage({
2✔
390
                        ...this.options,
391
                        logLevel: this.options.logLevel as LogLevel,
392
                        outDir: util.getOutDir(this.options),
393
                        outFile: path.basename(this.options.outFile)
394
                    });
395
                });
396
            }
397
        }
398
    }
399

400
    private transpileThrottler = new Throttler(0);
53✔
401
    /**
402
     * Transpiles the entire program into the staging folder
403
     */
404
    public async transpile() {
405
        await this.transpileThrottler.run(async () => {
4✔
406
            let options = util.cwdWork(this.options.cwd, () => {
4✔
407
                return rokuDeploy.getOptions({
4✔
408
                    ...this.options,
409
                    logLevel: this.options.logLevel as LogLevel,
410
                    outDir: util.getOutDir(this.options),
411
                    outFile: path.basename(this.options.outFile)
412
                });
413
            });
414

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

418
            //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
419
            let filteredFileMap = [] as FileObj[];
4✔
420
            for (let fileEntry of fileMap) {
4✔
421
                if (this.program.hasFile(fileEntry.src) === false) {
4✔
422
                    filteredFileMap.push(fileEntry);
2✔
423
                }
424
            }
425

426
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
427

428
            await this.logger.time(LogLevel.log, ['Copying to staging directory'], async () => {
4✔
429
                //prepublish all non-program-loaded files to staging
430
                await rokuDeploy.prepublishToStaging({
4✔
431
                    ...options,
432
                    files: filteredFileMap
433
                });
434
            });
435

436
            this.plugins.emit('afterPrepublish', this, filteredFileMap);
4✔
437
            this.plugins.emit('beforePublish', this, fileMap);
4✔
438

439
            await this.logger.time(LogLevel.log, ['Transpiling'], async () => {
4✔
440
                //transpile any brighterscript files
441
                await this.program.transpile(fileMap, options.stagingDir);
4✔
442
            });
443

444
            this.plugins.emit('afterPublish', this, fileMap);
4✔
445
        });
446
    }
447

448
    private async deployPackageIfEnabled() {
449
        //deploy the project if configured to do so
450
        if (this.options.deploy) {
39!
451
            await this.logger.time(LogLevel.log, ['Deploying package to', this.options.host], async () => {
×
452
                await rokuDeploy.publish({
×
453
                    ...this.options,
454
                    logLevel: this.options.logLevel as LogLevel,
455
                    outDir: util.getOutDir(this.options),
456
                    outFile: path.basename(this.options.outFile)
457
                });
458
            });
459
        }
460
    }
461

462
    /**
463
     * Parse and load the AST for every file in the project
464
     */
465
    private async loadAllFilesAST() {
466
        await this.logger.time(LogLevel.log, ['Parsing files'], async () => {
43✔
467
            let errorCount = 0;
43✔
468
            let files = await this.logger.time(LogLevel.debug, ['getFilePaths'], async () => {
43✔
469
                return util.getFilePaths(this.options);
43✔
470
            });
471
            this.logger.trace('ProgramBuilder.loadAllFilesAST() files:', files);
43✔
472

473
            const typedefFiles = [] as FileObj[];
43✔
474
            const nonTypedefFiles = [] as FileObj[];
43✔
475
            for (const file of files) {
43✔
476
                const srcLower = file.src.toLowerCase();
26✔
477
                if (srcLower.endsWith('.d.bs')) {
26✔
478
                    typedefFiles.push(file);
2✔
479
                } else {
480
                    nonTypedefFiles.push(file);
24✔
481
                }
482
            }
483

484
            //preload every type definition file first, which eliminates duplicate file loading
485
            await Promise.all(
43✔
486
                typedefFiles.map(async (fileObj) => {
487
                    try {
2✔
488
                        this.program.setFile(
2✔
489
                            fileObj,
490
                            await this.getFileContents(fileObj.src)
491
                        );
492
                    } catch (e) {
493
                        //log the error, but don't fail this process because the file might be fixable later
494
                        this.logger.log(e);
×
495
                    }
496
                })
497
            );
498

499
            const acceptableExtensions = ['.bs', '.brs', '.xml'];
43✔
500
            //parse every file other than the type definitions
501
            await Promise.all(
43✔
502
                nonTypedefFiles.map(async (fileObj) => {
503
                    try {
24✔
504
                        let fileExtension = path.extname(fileObj.src).toLowerCase();
24✔
505

506
                        //only process certain file types
507
                        if (acceptableExtensions.includes(fileExtension)) {
24✔
508
                            this.program.setFile(
20✔
509
                                fileObj,
510
                                await this.getFileContents(fileObj.src)
511
                            );
512
                        }
513
                    } catch (e) {
514
                        //log the error, but don't fail this process because the file might be fixable later
515
                        this.logger.log(e);
×
516
                    }
517
                })
518
            );
519
            return errorCount;
43✔
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();
40✔
542
    }
543

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