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

rokucommunity / brighterscript / #10790

18 Sep 2023 06:25PM UTC coverage: 88.054% (-0.03%) from 88.085%
#10790

push

web-flow
add noProject flag to bsc so BSConfig.json not expected (#868)

* initial commit

* update comments

* update code to match suggested comments

* remove developer logging

---------

Co-authored-by: Bronley Plumb <bronley@gmail.com>

5636 of 6870 branches covered (0.0%)

Branch coverage included in aggregate %.

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

8524 of 9211 relevant lines covered (92.54%)

1619.43 hits per line

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

70.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 { 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.noProject) {
41!
103
                this.logger.log(`'no-project' flag is set so bsconfig.json loading is disabled'`);
×
104
            } else if (this.options.project) {
41✔
105
                this.logger.log(`Using config file: "${this.options.project}"`);
9✔
106
            } else {
107
                this.logger.log(`No bsconfig.json file found, using default options`);
32✔
108
            }
109
            this.loadRequires();
41✔
110
            this.loadPlugins();
41✔
111
        } catch (e: any) {
112
            if (e?.file && e.message && e.code) {
1!
113
                let err = e as BsDiagnostic;
1✔
114
                this.staticDiagnostics.push(err);
1✔
115
            } else {
116
                //if this is not a diagnostic, something else is wrong...
117
                throw e;
×
118
            }
119
            this.printDiagnostics();
1✔
120

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

127
        this.program = this.createProgram();
42✔
128

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

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

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

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

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

160
        this.plugins.emit('beforeProgramCreate', this);
41✔
161
    }
162

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

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

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

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

192
        //clear the console
193
        this.clearConsole();
×
194

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

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

203
        this.logger.log('Watching for file changes...');
×
204

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

370
            //deploy the package
371
            await this.deployPackageIfEnabled();
39✔
372

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

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

385
            //transpile the project
386
            await this.transpile();
4✔
387

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

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

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

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

428
            this.plugins.emit('beforePrepublish', this, filteredFileMap);
4✔
429

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

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

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

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

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

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

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

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

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

508
                        //only process certain file types
509
                        if (acceptableExtensions.includes(fileExtension)) {
24✔
510
                            this.program.setFile(
20✔
511
                                fileObj,
512
                                await this.getFileContents(fileObj.src)
513
                            );
514
                        }
515
                    } catch (e) {
516
                        //log the error, but don't fail this process because the file might be fixable later
517
                        this.logger.log(e);
×
518
                    }
519
                })
520
            );
521
            return errorCount;
43✔
522
        });
523
    }
524

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

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

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