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

rokucommunity / brighterscript / #13816

05 Apr 2024 01:47PM UTC coverage: 89.047% (+0.6%) from 88.473%
#13816

push

TwitchBronBron
Fix completions crash

6410 of 7642 branches covered (83.88%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

181 existing lines in 11 files now uncovered.

9281 of 9979 relevant lines covered (93.01%)

1693.04 hits per line

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

94.05
/src/Program.ts
1
import * as assert from 'assert';
1✔
2
import * as fsExtra from 'fs-extra';
1✔
3
import * as path from 'path';
1✔
4
import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken } from 'vscode-languageserver';
5
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
1✔
6
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
7
import { Scope } from './Scope';
1✔
8
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
9
import { BrsFile } from './files/BrsFile';
1✔
10
import { XmlFile } from './files/XmlFile';
1✔
11
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from './interfaces';
12
import { standardizePath as s, util } from './util';
1✔
13
import { XmlScope } from './XmlScope';
1✔
14
import { DiagnosticFilterer } from './DiagnosticFilterer';
1✔
15
import { DependencyGraph } from './DependencyGraph';
1✔
16
import { Logger, LogLevel } from './Logger';
1✔
17
import chalk from 'chalk';
1✔
18
import { globalFile } from './globalCallables';
1✔
19
import { parseManifest, getBsConst } from './preprocessor/Manifest';
1✔
20
import { URI } from 'vscode-uri';
1✔
21
import PluginInterface from './PluginInterface';
1✔
22
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
1✔
23
import type { FunctionStatement, NamespaceStatement } from './parser/Statement';
24
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
25
import { AstEditor } from './astUtils/AstEditor';
1✔
26
import type { SourceMapGenerator } from 'source-map';
27
import type { Statement } from './parser/AstNode';
28
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
29
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
30
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
1✔
31
import { Sequencer } from './common/Sequencer';
1✔
32

33
const startOfSourcePkgPath = `source${path.sep}`;
1✔
34
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
35
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
36

37
export interface SourceObj {
38
    /**
39
     * @deprecated use `srcPath` instead
40
     */
41
    pathAbsolute: string;
42
    srcPath: string;
43
    source: string;
44
    definitions?: string;
45
}
46

47
export interface TranspileObj {
48
    file: BscFile;
49
    outputPath: string;
50
}
51

52
export interface SignatureInfoObj {
53
    index: number;
54
    key: string;
55
    signature: SignatureInformation;
56
}
57

58
export class Program {
1✔
59
    constructor(
60
        /**
61
         * The root directory for this program
62
         */
63
        options: BsConfig,
64
        logger?: Logger,
65
        plugins?: PluginInterface
66
    ) {
67
        this.options = util.normalizeConfig(options);
1,142✔
68
        this.logger = logger || new Logger(options.logLevel as LogLevel);
1,142✔
69
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,142✔
70

71
        //inject the bsc plugin as the first plugin in the stack.
72
        this.plugins.addFirst(new BscPlugin());
1,142✔
73

74
        //normalize the root dir path
75
        this.options.rootDir = util.getRootDir(this.options);
1,142✔
76

77
        this.createGlobalScope();
1,142✔
78
    }
79

80
    public options: FinalizedBsConfig;
81
    public logger: Logger;
82

83
    private createGlobalScope() {
84
        //create the 'global' scope
85
        this.globalScope = new Scope('global', this, 'scope:global');
1,142✔
86
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,142✔
87
        this.scopes.global = this.globalScope;
1,142✔
88
        //hardcode the files list for global scope to only contain the global file
89
        this.globalScope.getAllFiles = () => [globalFile];
17,413✔
90
        this.globalScope.validate();
1,142✔
91
        //for now, disable validation of global scope because the global files have some duplicate method declarations
92
        this.globalScope.getDiagnostics = () => [];
1,142✔
93
        //TODO we might need to fix this because the isValidated clears stuff now
94
        (this.globalScope as any).isValidated = true;
1,142✔
95
    }
96

97
    /**
98
     * A graph of all files and their dependencies.
99
     * For example:
100
     *      File.xml -> [lib1.brs, lib2.brs]
101
     *      lib2.brs -> [lib3.brs] //via an import statement
102
     */
103
    private dependencyGraph = new DependencyGraph();
1,142✔
104

105
    private diagnosticFilterer = new DiagnosticFilterer();
1,142✔
106

107
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,142✔
108

109
    /**
110
     * A scope that contains all built-in global functions.
111
     * All scopes should directly or indirectly inherit from this scope
112
     */
113
    public globalScope: Scope = undefined as any;
1,142✔
114

115
    /**
116
     * Plugins which can provide extra diagnostics or transform AST
117
     */
118
    public plugins: PluginInterface;
119

120
    /**
121
     * A set of diagnostics. This does not include any of the scope diagnostics.
122
     * Should only be set from `this.validate()`
123
     */
124
    private diagnostics = [] as BsDiagnostic[];
1,142✔
125

126
    /**
127
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
128
     */
129
    public get bslibPkgPath() {
130
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
131
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
401✔
132
            return bslibAliasedRokuModulesPkgPath;
2✔
133

134
            //if there's a non-aliased version of bslib from roku_modules, use that
135
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
399✔
136
            return bslibNonAliasedRokuModulesPkgPath;
3✔
137

138
            //default to the embedded version
139
        } else {
140
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
396✔
141
        }
142
    }
143

144
    public get bslibPrefix() {
145
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
374✔
146
            return 'rokucommunity_bslib';
3✔
147
        } else {
148
            return 'bslib';
371✔
149
        }
150
    }
151

152

153
    /**
154
     * A map of every file loaded into this program, indexed by its original file location
155
     */
156
    public files = {} as Record<string, BscFile>;
1,142✔
157
    private pkgMap = {} as Record<string, BscFile>;
1,142✔
158

159
    private scopes = {} as Record<string, Scope>;
1,142✔
160

161
    protected addScope(scope: Scope) {
162
        this.scopes[scope.name] = scope;
1,026✔
163
    }
164

165
    /**
166
     * A map of every component currently loaded into the program, indexed by the component name.
167
     * It is a compile-time error to have multiple components with the same name. However, we store an array of components
168
     * by name so we can provide a better developer expreience. You shouldn't be directly accessing this array,
169
     * but if you do, only ever use the component at index 0.
170
     */
171
    private components = {} as Record<string, { file: XmlFile; scope: XmlScope }[]>;
1,142✔
172

173
    /**
174
     * Get the component with the specified name
175
     */
176
    public getComponent(componentName: string) {
177
        if (componentName) {
484✔
178
            //return the first compoment in the list with this name
179
            //(components are ordered in this list by pkgPath to ensure consistency)
180
            return this.components[componentName.toLowerCase()]?.[0];
466✔
181
        } else {
182
            return undefined;
18✔
183
        }
184
    }
185

186
    /**
187
     * Register (or replace) the reference to a component in the component map
188
     */
189
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
190
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
208✔
191
        if (!this.components[key]) {
208✔
192
            this.components[key] = [];
196✔
193
        }
194
        this.components[key].push({
208✔
195
            file: xmlFile,
196
            scope: scope
197
        });
198
        this.components[key].sort((a, b) => {
208✔
199
            const pathA = a.file.pkgPath.toLowerCase();
5✔
200
            const pathB = b.file.pkgPath.toLowerCase();
5✔
201
            if (pathA < pathB) {
5✔
202
                return -1;
1✔
203
            } else if (pathA > pathB) {
4!
204
                return 1;
4✔
205
            }
206
            return 0;
×
207
        });
208
        this.syncComponentDependencyGraph(this.components[key]);
208✔
209
    }
210

211
    /**
212
     * Remove the specified component from the components map
213
     */
214
    private unregisterComponent(xmlFile: XmlFile) {
215
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
11✔
216
        const arr = this.components[key] || [];
11!
217
        for (let i = 0; i < arr.length; i++) {
11✔
218
            if (arr[i].file === xmlFile) {
11!
219
                arr.splice(i, 1);
11✔
220
                break;
11✔
221
            }
222
        }
223
        this.syncComponentDependencyGraph(arr);
11✔
224
    }
225

226
    /**
227
     * re-attach the dependency graph with a new key for any component who changed
228
     * their position in their own named array (only matters when there are multiple
229
     * components with the same name)
230
     */
231
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
232
        //reattach every dependency graph
233
        for (let i = 0; i < components.length; i++) {
219✔
234
            const { file, scope } = components[i];
214✔
235

236
            //attach (or re-attach) the dependencyGraph for every component whose position changed
237
            if (file.dependencyGraphIndex !== i) {
214✔
238
                file.dependencyGraphIndex = i;
210✔
239
                file.attachDependencyGraph(this.dependencyGraph);
210✔
240
                scope.attachDependencyGraph(this.dependencyGraph);
210✔
241
            }
242
        }
243
    }
244

245
    /**
246
     * Get a list of all files that are included in the project but are not referenced
247
     * by any scope in the program.
248
     */
249
    public getUnreferencedFiles() {
250
        let result = [] as File[];
703✔
251
        for (let filePath in this.files) {
703✔
252
            let file = this.files[filePath];
810✔
253
            //is this file part of a scope
254
            if (!this.getFirstScopeForFile(file)) {
810✔
255
                //no scopes reference this file. add it to the list
256
                result.push(file);
9✔
257
            }
258
        }
259
        return result;
703✔
260
    }
261

262
    /**
263
     * Get the list of errors for the entire program. It's calculated on the fly
264
     * by walking through every file, so call this sparingly.
265
     */
266
    public getDiagnostics() {
267
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
703✔
268

269
            let diagnostics = [...this.diagnostics];
703✔
270

271
            //get the diagnostics from all scopes
272
            for (let scopeName in this.scopes) {
703✔
273
                let scope = this.scopes[scopeName];
1,413✔
274
                diagnostics.push(
1,413✔
275
                    ...scope.getDiagnostics()
276
                );
277
            }
278

279
            //get the diagnostics from all unreferenced files
280
            let unreferencedFiles = this.getUnreferencedFiles();
703✔
281
            for (let file of unreferencedFiles) {
703✔
282
                diagnostics.push(
9✔
283
                    ...file.getDiagnostics()
284
                );
285
            }
286
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
703✔
287
                //filter out diagnostics based on our diagnostic filters
288
                let finalDiagnostics = this.diagnosticFilterer.filter({
703✔
289
                    ...this.options,
290
                    rootDir: this.options.rootDir
291
                }, diagnostics);
292
                return finalDiagnostics;
703✔
293
            });
294

295
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
703✔
296
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
703✔
297
            });
298

299
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
703✔
300
            return filteredDiagnostics;
703✔
301
        });
302
    }
303

304
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
305
        this.diagnostics.push(...diagnostics);
10✔
306
    }
307

308
    /**
309
     * Determine if the specified file is loaded in this program right now.
310
     * @param filePath the absolute or relative path to the file
311
     * @param normalizePath should the provided path be normalized before use
312
     */
313
    public hasFile(filePath: string, normalizePath = true) {
1,280✔
314
        return !!this.getFile(filePath, normalizePath);
1,280✔
315
    }
316

317
    public getPkgPath(...args: any[]): any { //eslint-disable-line
318
        throw new Error('Not implemented');
×
319
    }
320

321
    /**
322
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
323
     */
324
    public getScopeByName(scopeName: string): Scope | undefined {
325
        if (!scopeName) {
35!
326
            return undefined;
×
327
        }
328
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
329
        //so it's safe to run the standardizePkgPath method
330
        scopeName = s`${scopeName}`;
35✔
331
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
86✔
332
        return this.scopes[key!];
35✔
333
    }
334

335
    /**
336
     * Return all scopes
337
     */
338
    public getScopes() {
339
        return Object.values(this.scopes);
9✔
340
    }
341

342
    /**
343
     * Find the scope for the specified component
344
     */
345
    public getComponentScope(componentName: string) {
346
        return this.getComponent(componentName)?.scope;
139✔
347
    }
348

349
    /**
350
     * Update internal maps with this file reference
351
     */
352
    private assignFile<T extends BscFile = BscFile>(file: T) {
353
        this.files[file.srcPath.toLowerCase()] = file;
1,245✔
354
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,245✔
355
        return file;
1,245✔
356
    }
357

358
    /**
359
     * Remove this file from internal maps
360
     */
361
    private unassignFile<T extends BscFile = BscFile>(file: T) {
362
        delete this.files[file.srcPath.toLowerCase()];
106✔
363
        delete this.pkgMap[file.pkgPath.toLowerCase()];
106✔
364
        return file;
106✔
365
    }
366

367
    /**
368
     * Load a file into the program. If that file already exists, it is replaced.
369
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
370
     * @param srcPath the file path relative to the root dir
371
     * @param fileContents the file contents
372
     * @deprecated use `setFile` instead
373
     */
374
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
375
    /**
376
     * Load a file into the program. If that file already exists, it is replaced.
377
     * @param fileEntry an object that specifies src and dest for the file.
378
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
379
     * @deprecated use `setFile` instead
380
     */
381
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
382
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
383
        return this.setFile<T>(fileParam as any, fileContents);
1✔
384
    }
385

386
    /**
387
     * Load a file into the program. If that file already exists, it is replaced.
388
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
389
     * @param srcDestOrPkgPath the absolute path, the pkg path (i.e. `pkg:/path/to/file.brs`), or the destPath (i.e. `path/to/file.brs` relative to `pkg:/`)
390
     * @param fileContents the file contents
391
     */
392
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
393
    /**
394
     * Load a file into the program. If that file already exists, it is replaced.
395
     * @param fileEntry an object that specifies src and dest for the file.
396
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
397
     */
398
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
399
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
400
        //normalize the file paths
401
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,249✔
402

403
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,249✔
404
            //if the file is already loaded, remove it
405
            if (this.hasFile(srcPath)) {
1,249✔
406
                this.removeFile(srcPath);
92✔
407
            }
408
            let fileExtension = path.extname(srcPath).toLowerCase();
1,249✔
409
            let file: BscFile | undefined;
410

411
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,249✔
412
                //add the file to the program
413
                const brsFile = this.assignFile(
1,037✔
414
                    new BrsFile(srcPath, pkgPath, this)
415
                );
416

417
                //add file to the `source` dependency list
418
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,037✔
419
                    this.createSourceScope();
902✔
420
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
902✔
421
                }
422

423
                let sourceObj: SourceObj = {
1,037✔
424
                    //TODO remove `pathAbsolute` in v1
425
                    pathAbsolute: srcPath,
426
                    srcPath: srcPath,
427
                    source: fileContents
428
                };
429
                this.plugins.emit('beforeFileParse', sourceObj);
1,037✔
430

431
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,037✔
432
                    brsFile.parse(sourceObj.source);
1,037✔
433
                });
434

435
                //notify plugins that this file has finished parsing
436
                this.plugins.emit('afterFileParse', brsFile);
1,037✔
437

438
                file = brsFile;
1,037✔
439

440
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,037✔
441

442
            } else if (
212✔
443
                //is xml file
444
                fileExtension === '.xml' &&
423✔
445
                //resides in the components folder (Roku will only parse xml files in the components folder)
446
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
447
            ) {
448
                //add the file to the program
449
                const xmlFile = this.assignFile(
208✔
450
                    new XmlFile(srcPath, pkgPath, this)
451
                );
452

453
                let sourceObj: SourceObj = {
208✔
454
                    //TODO remove `pathAbsolute` in v1
455
                    pathAbsolute: srcPath,
456
                    srcPath: srcPath,
457
                    source: fileContents
458
                };
459
                this.plugins.emit('beforeFileParse', sourceObj);
208✔
460

461
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
208✔
462
                    xmlFile.parse(sourceObj.source);
208✔
463
                });
464

465
                //notify plugins that this file has finished parsing
466
                this.plugins.emit('afterFileParse', xmlFile);
208✔
467

468
                file = xmlFile;
208✔
469

470
                //create a new scope for this xml file
471
                let scope = new XmlScope(xmlFile, this);
208✔
472
                this.addScope(scope);
208✔
473

474
                //register this compoent now that we have parsed it and know its component name
475
                this.registerComponent(xmlFile, scope);
208✔
476

477
                //notify plugins that the scope is created and the component is registered
478
                this.plugins.emit('afterScopeCreate', scope);
208✔
479
            } else {
480
                //TODO do we actually need to implement this? Figure out how to handle img paths
481
                // let genericFile = this.files[srcPath] = <any>{
482
                //     srcPath: srcPath,
483
                //     pkgPath: pkgPath,
484
                //     wasProcessed: true
485
                // } as File;
486
                // file = <any>genericFile;
487
            }
488
            return file;
1,249✔
489
        });
490
        return file as T;
1,249✔
491
    }
492

493
    /**
494
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
495
     * @param fileParam an object representing file paths
496
     * @param rootDir must be a pre-normalized path
497
     */
498
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
499
        let srcPath: string | undefined;
500
        let pkgPath: string | undefined;
501

502
        assert.ok(fileParam, 'fileParam is required');
1,256✔
503

504
        //lift the srcPath and pkgPath vars from the incoming param
505
        if (typeof fileParam === 'string') {
1,256✔
506
            fileParam = this.removePkgPrefix(fileParam);
946✔
507
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
946✔
508
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
946✔
509
        } else {
510
            let param: any = fileParam;
310✔
511

512
            if (param.src) {
310✔
513
                srcPath = s`${param.src}`;
307✔
514
            }
515
            if (param.srcPath) {
310✔
516
                srcPath = s`${param.srcPath}`;
2✔
517
            }
518
            if (param.dest) {
310✔
519
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
307✔
520
            }
521
            if (param.pkgPath) {
310✔
522
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
523
            }
524
        }
525

526
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
527
        if (!srcPath) {
1,256✔
528
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
529
        }
530
        //coerce srcPath to an absolute path
531
        if (!path.isAbsolute(srcPath)) {
1,256✔
532
            srcPath = util.standardizePath(srcPath);
1✔
533
        }
534

535
        //if there's no pkgPath, compute relative path from rootDir
536
        if (!pkgPath) {
1,256✔
537
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
538
        }
539

540
        assert.ok(srcPath, 'fileEntry.src is required');
1,256✔
541
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,256✔
542

543
        return {
1,256✔
544
            srcPath: srcPath,
545
            //remove leading slash from pkgPath
546
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
547
        };
548
    }
549

550
    /**
551
     * Remove any leading `pkg:/` found in the path
552
     */
553
    private removePkgPrefix(path: string) {
554
        return path.replace(/^pkg:\//i, '');
1,255✔
555
    }
556

557
    /**
558
     * Ensure source scope is created.
559
     * Note: automatically called internally, and no-op if it exists already.
560
     */
561
    public createSourceScope() {
562
        if (!this.scopes.source) {
1,156✔
563
            const sourceScope = new Scope('source', this, 'scope:source');
818✔
564
            sourceScope.attachDependencyGraph(this.dependencyGraph);
818✔
565
            this.addScope(sourceScope);
818✔
566
            this.plugins.emit('afterScopeCreate', sourceScope);
818✔
567
        }
568
    }
569

570
    /**
571
     * Find the file by its absolute path. This is case INSENSITIVE, since
572
     * Roku is a case insensitive file system. It is an error to have multiple files
573
     * with the same path with only case being different.
574
     * @param srcPath the absolute path to the file
575
     * @deprecated use `getFile` instead, which auto-detects the path type
576
     */
577
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
578
        srcPath = s`${srcPath}`;
×
579
        for (let filePath in this.files) {
×
580
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
581
                return this.files[filePath] as T;
×
582
            }
583
        }
584
    }
585

586
    /**
587
     * Get a list of files for the given (platform-normalized) pkgPath array.
588
     * Missing files are just ignored.
589
     * @deprecated use `getFiles` instead, which auto-detects the path types
590
     */
591
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
592
        return pkgPaths
×
593
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
594
            .filter(file => file !== undefined) as T;
×
595
    }
596

597
    /**
598
     * Get a file with the specified (platform-normalized) pkg path.
599
     * If not found, return undefined
600
     * @deprecated use `getFile` instead, which auto-detects the path type
601
     */
602
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
603
        return this.pkgMap[pkgPath.toLowerCase()] as T;
340✔
604
    }
605

606
    /**
607
     * Remove a set of files from the program
608
     * @param srcPaths can be an array of srcPath or destPath strings
609
     * @param normalizePath should this function repair and standardize the filePaths? Passing false should have a performance boost if you can guarantee your paths are already sanitized
610
     */
611
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
612
        for (let srcPath of srcPaths) {
1✔
613
            this.removeFile(srcPath, normalizePath);
1✔
614
        }
615
    }
616

617
    /**
618
     * Remove a file from the program
619
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
620
     * @param normalizePath should this function repair and standardize the path? Passing false should have a performance boost if you can guarantee your path is already sanitized
621
     */
622
    public removeFile(filePath: string, normalizePath = true) {
102✔
623
        this.logger.debug('Program.removeFile()', filePath);
106✔
624

625
        let file = this.getFile(filePath, normalizePath);
106✔
626
        if (file) {
106!
627
            this.plugins.emit('beforeFileDispose', file);
106✔
628

629
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
630
            let scope = this.scopes[file.pkgPath];
106✔
631
            if (scope) {
106✔
632
                this.plugins.emit('beforeScopeDispose', scope);
11✔
633
                scope.dispose();
11✔
634
                //notify dependencies of this scope that it has been removed
635
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
636
                delete this.scopes[file.pkgPath];
11✔
637
                this.plugins.emit('afterScopeDispose', scope);
11✔
638
            }
639
            //remove the file from the program
640
            this.unassignFile(file);
106✔
641

642
            this.dependencyGraph.remove(file.dependencyGraphKey);
106✔
643

644
            //if this is a pkg:/source file, notify the `source` scope that it has changed
645
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
106✔
646
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
92✔
647
            }
648

649
            //if this is a component, remove it from our components map
650
            if (isXmlFile(file)) {
106✔
651
                this.unregisterComponent(file);
11✔
652
            }
653
            //dispose file
654
            file?.dispose();
106!
655
            this.plugins.emit('afterFileDispose', file);
106✔
656
        }
657
    }
658

659
    /**
660
     * Counter used to track which validation run is being logged
661
     */
662
    private validationRunSequence = 0;
1,142✔
663

664
    /**
665
     * Traverse the entire project, and validate all scopes
666
     */
667
    public validate(): void;
668
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
669
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
670
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
671
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${this.logger.logLevel > LogLevel.log ? ` (run ${this.validationRunSequence++})` : ''}`);
707!
672

673
        const sequencer = new Sequencer({
707✔
674
            name: 'program.validate',
675
            async: options?.async ?? false,
4,242✔
676
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
4,242✔
677
            //how many milliseconds can pass while doing synchronous operations before we register a short timeout
678
            minSyncDuration: 150
679
        });
680

681
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
682
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
683
        return sequencer
707✔
684
            .once(() => {
685
                this.diagnostics = [];
707✔
686
                this.plugins.emit('beforeProgramValidate', this);
707✔
687
            })
688
            .forEach(Object.values(this.files), (file) => {
689
                if (!file.isValidated) {
873✔
690
                    this.plugins.emit('beforeFileValidate', {
845✔
691
                        program: this,
692
                        file: file
693
                    });
694

695
                    //emit an event to allow plugins to contribute to the file validation process
696
                    this.plugins.emit('onFileValidate', {
845✔
697
                        program: this,
698
                        file: file
699
                    });
700
                    //call file.validate() IF the file has that function defined
701
                    file.validate?.();
845!
702
                    file.isValidated = true;
844✔
703

704
                    this.plugins.emit('afterFileValidate', file);
844✔
705
                }
706
            })
707
            .forEach(Object.values(this.scopes), (scope) => {
708
                scope.linkSymbolTable();
1,464✔
709
                scope.validate();
1,464✔
710
                scope.unlinkSymbolTable();
1,464✔
711
            })
712
            .once(() => {
713
                this.detectDuplicateComponentNames();
706✔
714
                this.plugins.emit('afterProgramValidate', this);
706✔
715
            })
716
            .onCancel(() => {
UNCOV
717
                timeEnd('cancelled');
×
718
            })
719
            .onSuccess(() => {
720
                timeEnd();
706✔
721
            })
722
            .run();
723
    }
724

725
    /**
726
     * Flag all duplicate component names
727
     */
728
    private detectDuplicateComponentNames() {
729
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
706✔
730
            const file = this.files[filePath];
872✔
731
            //if this is an XmlFile, and it has a valid `componentName` property
732
            if (isXmlFile(file) && file.componentName?.text) {
872✔
733
                let lowerName = file.componentName.text.toLowerCase();
138✔
734
                if (!map[lowerName]) {
138✔
735
                    map[lowerName] = [];
135✔
736
                }
737
                map[lowerName].push(file);
138✔
738
            }
739
            return map;
872✔
740
        }, {});
741

742
        for (let name in componentsByName) {
706✔
743
            const xmlFiles = componentsByName[name];
135✔
744
            //add diagnostics for every duplicate component with this name
745
            if (xmlFiles.length > 1) {
135✔
746
                for (let xmlFile of xmlFiles) {
3✔
747
                    const { componentName } = xmlFile;
6✔
748
                    this.diagnostics.push({
6✔
749
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
750
                        range: xmlFile.componentName.range,
751
                        file: xmlFile,
752
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
753
                            return {
6✔
754
                                location: util.createLocation(
755
                                    URI.file(xmlFile.srcPath ?? xmlFile.srcPath).toString(),
18!
756
                                    x.componentName.range
757
                                ),
758
                                message: 'Also defined here'
759
                            };
760
                        })
761
                    });
762
                }
763
            }
764
        }
765
    }
766

767
    /**
768
     * Get the files for a list of filePaths
769
     * @param filePaths can be an array of srcPath or a destPath strings
770
     * @param normalizePath should this function repair and standardize the paths? Passing false should have a performance boost if you can guarantee your paths are already sanitized
771
     */
772
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
24✔
773
        return filePaths
24✔
774
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
775
            .filter(file => file !== undefined) as T[];
30✔
776
    }
777

778
    /**
779
     * Get the file at the given path
780
     * @param filePath can be a srcPath or a destPath
781
     * @param normalizePath should this function repair and standardize the path? Passing false should have a performance boost if you can guarantee your path is already sanitized
782
     */
783
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
5,155✔
784
        if (typeof filePath !== 'string') {
6,571✔
785
            return undefined;
1,419✔
786
        } else if (path.isAbsolute(filePath)) {
5,152✔
787
            return this.files[
2,663✔
788
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,663✔
789
            ] as T;
790
        } else {
791
            return this.pkgMap[
2,489✔
792
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,489!
793
            ] as T;
794
        }
795
    }
796

797
    /**
798
     * Get a list of all scopes the file is loaded into
799
     * @param file the file
800
     */
801
    public getScopesForFile(file: XmlFile | BrsFile | string) {
802

803
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
489✔
804

805
        let result = [] as Scope[];
489✔
806
        if (resolvedFile) {
489✔
807
            for (let key in this.scopes) {
488✔
808
                let scope = this.scopes[key];
1,013✔
809

810
                if (scope.hasFile(resolvedFile)) {
1,013✔
811
                    result.push(scope);
499✔
812
                }
813
            }
814
        }
815
        return result;
489✔
816
    }
817

818
    /**
819
     * Get the first found scope for a file.
820
     */
821
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
822
        for (let key in this.scopes) {
2,020✔
823
            let scope = this.scopes[key];
4,648✔
824

825
            if (scope.hasFile(file)) {
4,648✔
826
                return scope;
1,953✔
827
            }
828
        }
829
    }
830

831
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
832
        let results = new Map<Statement, FileLink<Statement>>();
38✔
833
        const filesSearched = new Set<BrsFile>();
38✔
834
        let lowerNamespaceName = namespaceName?.toLowerCase();
38✔
835
        let lowerName = name?.toLowerCase();
38!
836
        //look through all files in scope for matches
837
        for (const scope of this.getScopesForFile(originFile)) {
38✔
838
            for (const file of scope.getAllFiles()) {
38✔
839
                if (isXmlFile(file) || filesSearched.has(file)) {
44✔
840
                    continue;
3✔
841
                }
842
                filesSearched.add(file);
41✔
843

844
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
41✔
845
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
97✔
846
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
97✔
847
                        if (!results.has(statement)) {
36!
848
                            results.set(statement, { item: statement, file: file });
36✔
849
                        }
850
                    }
851
                }
852
            }
853
        }
854
        return [...results.values()];
38✔
855
    }
856

857
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
858
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
859
        const filesSearched = new Set<BrsFile>();
8✔
860

861
        //get all function names for the xml file and parents
862
        let funcNames = new Set<string>();
8✔
863
        let currentScope = scope;
8✔
864
        while (isXmlScope(currentScope)) {
8✔
865
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
866
                if (!filterName || name === filterName) {
14!
867
                    funcNames.add(name);
14✔
868
                }
869
            }
870
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
871
        }
872

873
        //look through all files in scope for matches
874
        for (const file of scope.getOwnFiles()) {
8✔
875
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
876
                continue;
8✔
877
            }
878
            filesSearched.add(file);
8✔
879

880
            for (const statement of file.parser.references.functionStatements) {
8✔
881
                if (funcNames.has(statement.name.text)) {
13!
882
                    if (!results.has(statement)) {
13!
883
                        results.set(statement, { item: statement, file: file });
13✔
884
                    }
885
                }
886
            }
887
        }
888
        return [...results.values()];
8✔
889
    }
890

891
    /**
892
     * Find all available completion items at the given position
893
     * @param filePath can be a srcPath or a destPath
894
     * @param position the position (line & column) where completions should be found
895
     */
896
    public getCompletions(filePath: string, position: Position) {
897
        let file = this.getFile(filePath);
74✔
898
        if (!file) {
74!
UNCOV
899
            return [];
×
900
        }
901

902
        //find the scopes for this file
903
        let scopes = this.getScopesForFile(file);
74✔
904

905
        //if there are no scopes, include the global scope so we at least get the built-in functions
906
        scopes = scopes.length > 0 ? scopes : [this.globalScope];
74✔
907

908
        const event: ProvideCompletionsEvent = {
74✔
909
            program: this,
910
            file: file,
911
            scopes: scopes,
912
            position: position,
913
            completions: []
914
        };
915

916
        this.plugins.emit('beforeProvideCompletions', event);
74✔
917

918
        this.plugins.emit('provideCompletions', event);
74✔
919

920
        this.plugins.emit('afterProvideCompletions', event);
74✔
921

922
        return event.completions;
74✔
923
    }
924

925
    /**
926
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
927
     */
928
    public getWorkspaceSymbols() {
929
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
930
            program: this,
931
            workspaceSymbols: []
932
        };
933
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
934
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
935
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
936
        return event.workspaceSymbols;
22✔
937
    }
938

939
    /**
940
     * Given a position in a file, if the position is sitting on some type of identifier,
941
     * go to the definition of that identifier (where this thing was first defined)
942
     */
943
    public getDefinition(srcPath: string, position: Position): Location[] {
944
        let file = this.getFile(srcPath);
13✔
945
        if (!file) {
13!
UNCOV
946
            return [];
×
947
        }
948

949
        const event: ProvideDefinitionEvent = {
13✔
950
            program: this,
951
            file: file,
952
            position: position,
953
            definitions: []
954
        };
955

956
        this.plugins.emit('beforeProvideDefinition', event);
13✔
957
        this.plugins.emit('provideDefinition', event);
13✔
958
        this.plugins.emit('afterProvideDefinition', event);
13✔
959
        return event.definitions;
13✔
960
    }
961

962
    /**
963
     * Get hover information for a file and position
964
     */
965
    public getHover(srcPath: string, position: Position): Hover[] {
966
        let file = this.getFile(srcPath);
30✔
967
        let result: Hover[];
968
        if (file) {
30!
969
            const event = {
30✔
970
                program: this,
971
                file: file,
972
                position: position,
973
                scopes: this.getScopesForFile(file),
974
                hovers: []
975
            } as ProvideHoverEvent;
976
            this.plugins.emit('beforeProvideHover', event);
30✔
977
            this.plugins.emit('provideHover', event);
30✔
978
            this.plugins.emit('afterProvideHover', event);
30✔
979
            result = event.hovers;
30✔
980
        }
981

982
        return result ?? [];
30!
983
    }
984

985
    /**
986
     * Get full list of document symbols for a file
987
     * @param srcPath path to the file
988
     */
989
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
990
        let file = this.getFile(srcPath);
24✔
991
        if (file) {
24!
992
            const event: ProvideDocumentSymbolsEvent = {
24✔
993
                program: this,
994
                file: file,
995
                documentSymbols: []
996
            };
997
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
998
            this.plugins.emit('provideDocumentSymbols', event);
24✔
999
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1000
            return event.documentSymbols;
24✔
1001
        } else {
UNCOV
1002
            return undefined;
×
1003
        }
1004
    }
1005

1006
    /**
1007
     * Compute code actions for the given file and range
1008
     */
1009
    public getCodeActions(srcPath: string, range: Range) {
1010
        const codeActions = [] as CodeAction[];
11✔
1011
        const file = this.getFile(srcPath);
11✔
1012
        if (file) {
11✔
1013
            const diagnostics = this
10✔
1014
                //get all current diagnostics (filtered by diagnostic filters)
1015
                .getDiagnostics()
1016
                //only keep diagnostics related to this file
1017
                .filter(x => x.file === file)
26✔
1018
                //only keep diagnostics that touch this range
1019
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
10✔
1020

1021
            const scopes = this.getScopesForFile(file);
10✔
1022

1023
            this.plugins.emit('onGetCodeActions', {
10✔
1024
                program: this,
1025
                file: file,
1026
                range: range,
1027
                diagnostics: diagnostics,
1028
                scopes: scopes,
1029
                codeActions: codeActions
1030
            });
1031
        }
1032
        return codeActions;
11✔
1033
    }
1034

1035
    /**
1036
     * Get semantic tokens for the specified file
1037
     */
1038
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1039
        const file = this.getFile(srcPath);
16✔
1040
        if (file) {
16!
1041
            const result = [] as SemanticToken[];
16✔
1042
            this.plugins.emit('onGetSemanticTokens', {
16✔
1043
                program: this,
1044
                file: file,
1045
                scopes: this.getScopesForFile(file),
1046
                semanticTokens: result
1047
            });
1048
            return result;
16✔
1049
        }
1050
    }
1051

1052
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1053
        let file: BrsFile = this.getFile(filePath);
183✔
1054
        if (!file || !isBrsFile(file)) {
183✔
1055
            return [];
2✔
1056
        }
1057
        let callExpressionInfo = new CallExpressionInfo(file, position);
181✔
1058
        let signatureHelpUtil = new SignatureHelpUtil();
181✔
1059
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
181✔
1060
    }
1061

1062
    public getReferences(srcPath: string, position: Position): Location[] {
1063
        //find the file
1064
        let file = this.getFile(srcPath);
4✔
1065
        if (!file) {
4!
UNCOV
1066
            return null;
×
1067
        }
1068

1069
        const event: ProvideReferencesEvent = {
4✔
1070
            program: this,
1071
            file: file,
1072
            position: position,
1073
            references: []
1074
        };
1075

1076
        this.plugins.emit('beforeProvideReferences', event);
4✔
1077
        this.plugins.emit('provideReferences', event);
4✔
1078
        this.plugins.emit('afterProvideReferences', event);
4✔
1079

1080
        return event.references;
4✔
1081
    }
1082

1083
    /**
1084
     * Get a list of all script imports, relative to the specified pkgPath
1085
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1086
     */
1087
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1088
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1089

1090
        let result = [] as CompletionItem[];
3✔
1091
        /**
1092
         * hashtable to prevent duplicate results
1093
         */
1094
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1095

1096
        //restrict to only .brs files
1097
        for (let key in this.files) {
3✔
1098
            let file = this.files[key];
4✔
1099
            if (
4✔
1100
                //is a BrightScript or BrighterScript file
1101
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1102
                //this file is not the current file
1103
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1104
            ) {
1105
                //add the relative path
1106
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1107
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1108
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1109
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1110
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1111
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1112

1113
                    result.push({
3✔
1114
                        label: relativePath,
1115
                        detail: file.srcPath,
1116
                        kind: CompletionItemKind.File,
1117
                        textEdit: {
1118
                            newText: relativePath,
1119
                            range: scriptImport.filePathRange
1120
                        }
1121
                    });
1122

1123
                    //add the absolute path
1124
                    result.push({
3✔
1125
                        label: filePkgPath,
1126
                        detail: file.srcPath,
1127
                        kind: CompletionItemKind.File,
1128
                        textEdit: {
1129
                            newText: filePkgPath,
1130
                            range: scriptImport.filePathRange
1131
                        }
1132
                    });
1133
                }
1134
            }
1135
        }
1136
        return result;
3✔
1137
    }
1138

1139
    /**
1140
     * Transpile a single file and get the result as a string.
1141
     * This does not write anything to the file system.
1142
     *
1143
     * This should only be called by `LanguageServer`.
1144
     * Internal usage should call `_getTranspiledFileContents` instead.
1145
     * @param filePath can be a srcPath or a destPath
1146
     */
1147
    public async getTranspiledFileContents(filePath: string) {
1148
        const file = this.getFile(filePath);
4✔
1149
        const fileMap: FileObj[] = [{
4✔
1150
            src: file.srcPath,
1151
            dest: file.pkgPath
1152
        }];
1153
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1154
        const result = this._getTranspiledFileContents(
4✔
1155
            file
1156
        );
1157
        this.afterProgramTranspile(entries, astEditor);
4✔
1158
        return Promise.resolve(result);
4✔
1159
    }
1160

1161
    /**
1162
     * Internal function used to transpile files.
1163
     * This does not write anything to the file system
1164
     */
1165
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1166
        const editor = new AstEditor();
274✔
1167
        this.plugins.emit('beforeFileTranspile', {
274✔
1168
            program: this,
1169
            file: file,
1170
            outputPath: outputPath,
1171
            editor: editor
1172
        });
1173

1174
        //if we have any edits, assume the file needs to be transpiled
1175
        if (editor.hasChanges) {
274✔
1176
            //use the `editor` because it'll track the previous value for us and revert later on
1177
            editor.setProperty(file, 'needsTranspiled', true);
37✔
1178
        }
1179

1180
        //transpile the file
1181
        const result = file.transpile();
274✔
1182

1183
        //generate the typedef if enabled
1184
        let typedef: string;
1185
        if (isBrsFile(file) && this.options.emitDefinitions) {
274✔
1186
            typedef = file.getTypedef();
2✔
1187
        }
1188

1189
        const event: AfterFileTranspileEvent = {
274✔
1190
            program: this,
1191
            file: file,
1192
            outputPath: outputPath,
1193
            editor: editor,
1194
            code: result.code,
1195
            map: result.map,
1196
            typedef: typedef
1197
        };
1198
        this.plugins.emit('afterFileTranspile', event);
274✔
1199

1200
        //undo all `editor` edits that may have been applied to this file.
1201
        editor.undoAll();
274✔
1202

1203
        return {
274✔
1204
            srcPath: file.srcPath,
1205
            pkgPath: file.pkgPath,
1206
            code: event.code,
1207
            map: event.map,
1208
            typedef: event.typedef
1209
        };
1210
    }
1211

1212
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1213
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1214
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1215
            collection[s`${entry.src}`] = entry;
20✔
1216
            return collection;
20✔
1217
        }, {});
1218

1219
        const getOutputPath = (file: BscFile) => {
37✔
1220
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1221
            if (!filePathObj) {
77✔
1222
                //this file has been added in-memory, from a plugin, for example
1223
                filePathObj = {
47✔
1224
                    //add an interpolated src path (since it doesn't actually exist in memory)
1225
                    src: `bsc:/${file.pkgPath}`,
1226
                    dest: file.pkgPath
1227
                };
1228
            }
1229
            //replace the file extension
1230
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1231
            //prepend the staging folder path
1232
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1233
            return outputPath;
77✔
1234
        };
1235

1236
        const entries = Object.values(this.files)
37✔
1237
            //only include the files from fileEntries
1238
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1239
            .map(file => {
1240
                return {
18✔
1241
                    file: file,
1242
                    outputPath: getOutputPath(file)
1243
                };
1244
            })
1245
            //sort the entries to make transpiling more deterministic
1246
            .sort((a, b) => {
1247
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1248
            });
1249

1250
        const astEditor = new AstEditor();
37✔
1251

1252
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1253
        return {
37✔
1254
            entries: entries,
1255
            getOutputPath: getOutputPath,
1256
            astEditor: astEditor
1257
        };
1258
    }
1259

1260
    public async transpile(fileEntries: FileObj[], stagingDir: string) {
1261
        const { entries, getOutputPath, astEditor } = this.beforeProgramTranspile(fileEntries, stagingDir);
32✔
1262

1263
        const processedFiles = new Set<string>();
32✔
1264

1265
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1266
            //find the file in the program
1267
            const file = this.getFile(srcPath);
35✔
1268
            //mark this file as processed so we don't process it more than once
1269
            processedFiles.add(outputPath?.toLowerCase());
35!
1270

1271
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1272
                //skip transpiling typedef files
1273
                if (isBrsFile(file) && file.isTypedef) {
34✔
1274
                    return;
1✔
1275
                }
1276

1277
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1278

1279
                //make sure the full dir path exists
1280
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1281

1282
                if (await fsExtra.pathExists(outputPath)) {
33!
UNCOV
1283
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1284
                }
1285
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1286
                await Promise.all([
33✔
1287
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1288
                    writeMapPromise
1289
                ]);
1290

1291
                if (fileTranspileResult.typedef) {
33✔
1292
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1293
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1294
                }
1295
            }
1296
        };
1297

1298
        let promises = entries.map(async (entry) => {
32✔
1299
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1300
        });
1301

1302
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1303
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1304
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1305
        }
1306
        await Promise.all(promises);
32✔
1307

1308
        //transpile any new files that plugins added since the start of this transpile process
1309
        do {
32✔
1310
            promises = [];
52✔
1311
            for (const key in this.files) {
52✔
1312
                const file = this.files[key];
59✔
1313
                //this is a new file
1314
                const outputPath = getOutputPath(file);
59✔
1315
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1316
                    promises.push(
23✔
1317
                        transpileFile(file?.srcPath, outputPath)
69!
1318
                    );
1319
                }
1320
            }
1321
            if (promises.length > 0) {
52✔
1322
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1323
                await Promise.all(promises);
20✔
1324
            }
1325
        }
1326
        while (promises.length > 0);
1327
        this.afterProgramTranspile(entries, astEditor);
32✔
1328
    }
1329

1330
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1331
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1332
        astEditor.undoAll();
36✔
1333
    }
1334

1335
    /**
1336
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1337
     */
1338
    public findFilesForFunction(functionName: string) {
1339
        const files = [] as BscFile[];
7✔
1340
        const lowerFunctionName = functionName.toLowerCase();
7✔
1341
        //find every file with this function defined
1342
        for (const file of Object.values(this.files)) {
7✔
1343
            if (isBrsFile(file)) {
25✔
1344
                //TODO handle namespace-relative function calls
1345
                //if the file has a function with this name
1346
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1347
                    files.push(file);
2✔
1348
                }
1349
            }
1350
        }
1351
        return files;
7✔
1352
    }
1353

1354
    /**
1355
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1356
     */
1357
    public findFilesForClass(className: string) {
1358
        const files = [] as BscFile[];
7✔
1359
        const lowerClassName = className.toLowerCase();
7✔
1360
        //find every file with this class defined
1361
        for (const file of Object.values(this.files)) {
7✔
1362
            if (isBrsFile(file)) {
25✔
1363
                //TODO handle namespace-relative classes
1364
                //if the file has a function with this name
1365
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1366
                    files.push(file);
1✔
1367
                }
1368
            }
1369
        }
1370
        return files;
7✔
1371
    }
1372

1373
    public findFilesForNamespace(name: string) {
1374
        const files = [] as BscFile[];
7✔
1375
        const lowerName = name.toLowerCase();
7✔
1376
        //find every file with this class defined
1377
        for (const file of Object.values(this.files)) {
7✔
1378
            if (isBrsFile(file)) {
25✔
1379
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1380
                    const namespaceName = x.name.toLowerCase();
7✔
1381
                    return (
7✔
1382
                        //the namespace name matches exactly
1383
                        namespaceName === lowerName ||
9✔
1384
                        //the full namespace starts with the name (honoring the part boundary)
1385
                        namespaceName.startsWith(lowerName + '.')
1386
                    );
1387
                })) {
1388
                    files.push(file);
6✔
1389
                }
1390
            }
1391
        }
1392
        return files;
7✔
1393
    }
1394

1395
    public findFilesForEnum(name: string) {
1396
        const files = [] as BscFile[];
8✔
1397
        const lowerName = name.toLowerCase();
8✔
1398
        //find every file with this class defined
1399
        for (const file of Object.values(this.files)) {
8✔
1400
            if (isBrsFile(file)) {
26✔
1401
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1402
                    files.push(file);
1✔
1403
                }
1404
            }
1405
        }
1406
        return files;
8✔
1407
    }
1408

1409
    private _manifest: Map<string, string>;
1410

1411
    /**
1412
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1413
     * @param parsedManifest The manifest map to read from and modify
1414
     */
1415
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1416
        // Lift the bs_consts defined in the manifest
1417
        let bsConsts = getBsConst(parsedManifest, false);
12✔
1418

1419
        // Override or delete any bs_consts defined in the bs config
1420
        for (const key in this.options?.manifest?.bs_const) {
12!
1421
            const value = this.options.manifest.bs_const[key];
3✔
1422
            if (value === null) {
3✔
1423
                bsConsts.delete(key);
1✔
1424
            } else {
1425
                bsConsts.set(key, value);
2✔
1426
            }
1427
        }
1428

1429
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1430
        let constString = '';
12✔
1431
        for (const [key, value] of bsConsts) {
12✔
1432
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1433
        }
1434

1435
        // Set the updated bs_const value
1436
        parsedManifest.set('bs_const', constString);
12✔
1437
    }
1438

1439
    /**
1440
     * Try to find and load the manifest into memory
1441
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1442
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1443
     */
1444
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
864✔
1445
        //if we already have a manifest instance, and should not replace...then don't replace
1446
        if (!replaceIfAlreadyLoaded && this._manifest) {
869!
UNCOV
1447
            return;
×
1448
        }
1449
        let manifestPath = manifestFileObj
869✔
1450
            ? manifestFileObj.src
869✔
1451
            : path.join(this.options.rootDir, 'manifest');
1452

1453
        try {
869✔
1454
            // we only load this manifest once, so do it sync to improve speed downstream
1455
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
869✔
1456
            const parsedManifest = parseManifest(contents);
12✔
1457
            this.buildBsConstsIntoParsedManifest(parsedManifest);
12✔
1458
            this._manifest = parsedManifest;
12✔
1459
        } catch (e) {
1460
            this._manifest = new Map();
857✔
1461
        }
1462
    }
1463

1464
    /**
1465
     * Get a map of the manifest information
1466
     */
1467
    public getManifest() {
1468
        if (!this._manifest) {
1,091✔
1469
            this.loadManifest();
863✔
1470
        }
1471
        return this._manifest;
1,091✔
1472
    }
1473

1474
    public dispose() {
1475
        this.plugins.emit('beforeProgramDispose', { program: this });
988✔
1476

1477
        for (let filePath in this.files) {
988✔
1478
            this.files[filePath].dispose();
1,100✔
1479
        }
1480
        for (let name in this.scopes) {
988✔
1481
            this.scopes[name].dispose();
1,962✔
1482
        }
1483
        this.globalScope.dispose();
988✔
1484
        this.dependencyGraph.dispose();
988✔
1485
    }
1486
}
1487

1488
export interface FileTranspileResult {
1489
    srcPath: string;
1490
    pkgPath: string;
1491
    code: string;
1492
    map: SourceMapGenerator;
1493
    typedef: string;
1494
}
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