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

rokucommunity / brighterscript / #13779

29 Feb 2024 09:40PM UTC coverage: 88.196% (+0.2%) from 87.991%
#13779

push

TwitchBronBron
0.65.23

5893 of 7167 branches covered (82.22%)

Branch coverage included in aggregate %.

8737 of 9421 relevant lines covered (92.74%)

1688.27 hits per line

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

93.77
/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 } from 'vscode-languageserver';
5
import { 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 } 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 { rokuDeploy } from 'roku-deploy';
1✔
28
import type { Statement } from './parser/AstNode';
29
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
30
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
31
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
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,086✔
68
        this.logger = logger || new Logger(options.logLevel as LogLevel);
1,086✔
69
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,086✔
70

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

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

77
        this.createGlobalScope();
1,086✔
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,086✔
86
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,086✔
87
        this.scopes.global = this.globalScope;
1,086✔
88
        //hardcode the files list for global scope to only contain the global file
89
        this.globalScope.getAllFiles = () => [globalFile];
16,568✔
90
        this.globalScope.validate();
1,086✔
91
        //for now, disable validation of global scope because the global files have some duplicate method declarations
92
        this.globalScope.getDiagnostics = () => [];
1,086✔
93
        //TODO we might need to fix this because the isValidated clears stuff now
94
        (this.globalScope as any).isValidated = true;
1,086✔
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,086✔
104

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

107
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,086✔
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,086✔
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,086✔
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)) {
393✔
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)) {
391✔
136
            return bslibNonAliasedRokuModulesPkgPath;
3✔
137

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

144
    public get bslibPrefix() {
145
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
366✔
146
            return 'rokucommunity_bslib';
3✔
147
        } else {
148
            return 'bslib';
363✔
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,086✔
157
    private pkgMap = {} as Record<string, BscFile>;
1,086✔
158

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

161
    protected addScope(scope: Scope) {
162
        this.scopes[scope.name] = scope;
987✔
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,086✔
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();
206✔
191
        if (!this.components[key]) {
206✔
192
            this.components[key] = [];
194✔
193
        }
194
        this.components[key].push({
206✔
195
            file: xmlFile,
196
            scope: scope
197
        });
198
        this.components[key].sort((a, b) => {
206✔
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]);
206✔
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++) {
217✔
234
            const { file, scope } = components[i];
212✔
235

236
            //attach (or re-attach) the dependencyGraph for every component whose position changed
237
            if (file.dependencyGraphIndex !== i) {
212✔
238
                file.dependencyGraphIndex = i;
208✔
239
                file.attachDependencyGraph(this.dependencyGraph);
208✔
240
                scope.attachDependencyGraph(this.dependencyGraph);
208✔
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[];
648✔
251
        for (let filePath in this.files) {
648✔
252
            let file = this.files[filePath];
827✔
253
            //is this file part of a scope
254
            if (!this.getFirstScopeForFile(file)) {
827✔
255
                //no scopes reference this file. add it to the list
256
                result.push(file);
20✔
257
            }
258
        }
259
        return result;
648✔
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()'], () => {
648✔
268

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

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

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

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

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

304
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
305
        this.diagnostics.push(...diagnostics);
12✔
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,192✔
314
        return !!this.getFile(filePath, normalizePath);
1,192✔
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,174✔
354
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,174✔
355
        return file;
1,174✔
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()];
76✔
363
        delete this.pkgMap[file.pkgPath.toLowerCase()];
76✔
364
        return file;
76✔
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,177✔
402

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

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

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

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

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

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

438
                file = brsFile;
968✔
439

440
                brsFile.attachDependencyGraph(this.dependencyGraph);
968✔
441

442
            } else if (
209✔
443
                //is xml file
444
                fileExtension === '.xml' &&
418✔
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(
206✔
450
                    new XmlFile(srcPath, pkgPath, this)
451
                );
452

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

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

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

468
                file = xmlFile;
206✔
469

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

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

477
                //notify plugins that the scope is created and the component is registered
478
                this.plugins.emit('afterScopeCreate', scope);
206✔
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,177✔
489
        });
490
        return file as T;
1,177✔
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,184✔
503

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

512
            if (param.src) {
282✔
513
                srcPath = s`${param.src}`;
279✔
514
            }
515
            if (param.srcPath) {
282✔
516
                srcPath = s`${param.srcPath}`;
2✔
517
            }
518
            if (param.dest) {
282✔
519
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
279✔
520
            }
521
            if (param.pkgPath) {
282✔
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,184✔
528
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
529
        }
530
        //coerce srcPath to an absolute path
531
        if (!path.isAbsolute(srcPath)) {
1,184✔
532
            srcPath = util.standardizePath(srcPath);
1✔
533
        }
534

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

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

543
        return {
1,184✔
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,183✔
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,084✔
563
            const sourceScope = new Scope('source', this, 'scope:source');
781✔
564
            sourceScope.attachDependencyGraph(this.dependencyGraph);
781✔
565
            this.addScope(sourceScope);
781✔
566
            this.plugins.emit('afterScopeCreate', sourceScope);
781✔
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;
347✔
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) {
75✔
623
        this.logger.debug('Program.removeFile()', filePath);
76✔
624

625
        let file = this.getFile(filePath, normalizePath);
76✔
626
        if (file) {
76!
627
            this.plugins.emit('beforeFileDispose', file);
76✔
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];
76✔
631
            if (scope) {
76✔
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);
76✔
641

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

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

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

659
    /**
660
     * Traverse the entire project, and validate all scopes
661
     */
662
    public validate() {
663
        this.logger.time(LogLevel.log, ['Validating project'], () => {
662✔
664
            this.diagnostics = [];
662✔
665
            this.plugins.emit('beforeProgramValidate', this);
662✔
666

667
            //validate every file
668
            for (const file of Object.values(this.files)) {
662✔
669
                //for every unvalidated file, validate it
670
                if (!file.isValidated) {
839✔
671
                    this.plugins.emit('beforeFileValidate', {
811✔
672
                        program: this,
673
                        file: file
674
                    });
675

676
                    //emit an event to allow plugins to contribute to the file validation process
677
                    this.plugins.emit('onFileValidate', {
811✔
678
                        program: this,
679
                        file: file
680
                    });
681
                    //call file.validate() IF the file has that function defined
682
                    file.validate?.();
811!
683
                    file.isValidated = true;
811✔
684

685
                    this.plugins.emit('afterFileValidate', file);
811✔
686
                }
687
            }
688

689
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
662✔
690
                for (let scopeName in this.scopes) {
662✔
691
                    let scope = this.scopes[scopeName];
1,393✔
692
                    scope.linkSymbolTable();
1,393✔
693
                    scope.validate();
1,393✔
694
                    scope.unlinkSymbolTable();
1,393✔
695
                }
696
            });
697

698
            this.detectDuplicateComponentNames();
662✔
699

700
            this.plugins.emit('afterProgramValidate', this);
662✔
701
        });
702
    }
703

704
    /**
705
     * Flag all duplicate component names
706
     */
707
    private detectDuplicateComponentNames() {
708
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
662✔
709
            const file = this.files[filePath];
839✔
710
            //if this is an XmlFile, and it has a valid `componentName` property
711
            if (isXmlFile(file) && file.componentName?.text) {
839✔
712
                let lowerName = file.componentName.text.toLowerCase();
138✔
713
                if (!map[lowerName]) {
138✔
714
                    map[lowerName] = [];
135✔
715
                }
716
                map[lowerName].push(file);
138✔
717
            }
718
            return map;
839✔
719
        }, {});
720

721
        for (let name in componentsByName) {
662✔
722
            const xmlFiles = componentsByName[name];
135✔
723
            //add diagnostics for every duplicate component with this name
724
            if (xmlFiles.length > 1) {
135✔
725
                for (let xmlFile of xmlFiles) {
3✔
726
                    const { componentName } = xmlFile;
6✔
727
                    this.diagnostics.push({
6✔
728
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
729
                        range: xmlFile.componentName.range,
730
                        file: xmlFile,
731
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
732
                            return {
6✔
733
                                location: util.createLocation(
734
                                    URI.file(xmlFile.srcPath ?? xmlFile.srcPath).toString(),
18!
735
                                    x.componentName.range
736
                                ),
737
                                message: 'Also defined here'
738
                            };
739
                        })
740
                    });
741
                }
742
            }
743
        }
744
    }
745

746
    /**
747
     * Get the files for a list of filePaths
748
     * @param filePaths can be an array of srcPath or a destPath strings
749
     * @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
750
     */
751
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
24✔
752
        return filePaths
24✔
753
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
754
            .filter(file => file !== undefined) as T[];
30✔
755
    }
756

757
    /**
758
     * Get the file at the given path
759
     * @param filePath can be a srcPath or a destPath
760
     * @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
761
     */
762
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
4,862✔
763
        if (typeof filePath !== 'string') {
6,160✔
764
            return undefined;
1,395✔
765
        } else if (path.isAbsolute(filePath)) {
4,765✔
766
            return this.files[
2,393✔
767
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,393!
768
            ] as T;
769
        } else {
770
            return this.pkgMap[
2,372✔
771
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,372!
772
            ] as T;
773
        }
774
    }
775

776
    /**
777
     * Get a list of all scopes the file is loaded into
778
     * @param file the file
779
     */
780
    public getScopesForFile(file: XmlFile | BrsFile | string) {
781

782
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
631✔
783

784
        let result = [] as Scope[];
631✔
785
        if (resolvedFile) {
631✔
786
            for (let key in this.scopes) {
630✔
787
                let scope = this.scopes[key];
1,251✔
788

789
                if (scope.hasFile(resolvedFile)) {
1,251✔
790
                    result.push(scope);
594✔
791
                }
792
            }
793
        }
794
        return result;
631✔
795
    }
796

797
    /**
798
     * Get the first found scope for a file.
799
     */
800
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
801
        for (let key in this.scopes) {
1,768✔
802
            let scope = this.scopes[key];
4,218✔
803

804
            if (scope.hasFile(file)) {
4,218✔
805
                return scope;
1,735✔
806
            }
807
        }
808
    }
809

810
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
811
        let results = new Map<Statement, FileLink<Statement>>();
38✔
812
        const filesSearched = new Set<BrsFile>();
38✔
813
        let lowerNamespaceName = namespaceName?.toLowerCase();
38✔
814
        let lowerName = name?.toLowerCase();
38!
815
        //look through all files in scope for matches
816
        for (const scope of this.getScopesForFile(originFile)) {
38✔
817
            for (const file of scope.getAllFiles()) {
38✔
818
                if (isXmlFile(file) || filesSearched.has(file)) {
44✔
819
                    continue;
3✔
820
                }
821
                filesSearched.add(file);
41✔
822

823
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
41✔
824
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
97✔
825
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
97✔
826
                        if (!results.has(statement)) {
36!
827
                            results.set(statement, { item: statement, file: file });
36✔
828
                        }
829
                    }
830
                }
831
            }
832
        }
833
        return [...results.values()];
38✔
834
    }
835

836
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
837
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
838
        const filesSearched = new Set<BrsFile>();
8✔
839

840
        //get all function names for the xml file and parents
841
        let funcNames = new Set<string>();
8✔
842
        let currentScope = scope;
8✔
843
        while (isXmlScope(currentScope)) {
8✔
844
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
845
                if (!filterName || name === filterName) {
14!
846
                    funcNames.add(name);
14✔
847
                }
848
            }
849
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
850
        }
851

852
        //look through all files in scope for matches
853
        for (const file of scope.getOwnFiles()) {
8✔
854
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
855
                continue;
8✔
856
            }
857
            filesSearched.add(file);
8✔
858

859
            for (const statement of file.parser.references.functionStatements) {
8✔
860
                if (funcNames.has(statement.name.text)) {
13!
861
                    if (!results.has(statement)) {
13!
862
                        results.set(statement, { item: statement, file: file });
13✔
863
                    }
864
                }
865
            }
866
        }
867
        return [...results.values()];
8✔
868
    }
869

870
    /**
871
     * Find all available completion items at the given position
872
     * @param filePath can be a srcPath or a destPath
873
     * @param position the position (line & column) where completions should be found
874
     */
875
    public getCompletions(filePath: string, position: Position) {
876
        let file = this.getFile(filePath);
74✔
877
        if (!file) {
74!
878
            return [];
×
879
        }
880

881
        //find the scopes for this file
882
        let scopes = this.getScopesForFile(file);
74✔
883

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

887
        const event: ProvideCompletionsEvent = {
74✔
888
            program: this,
889
            file: file,
890
            scopes: scopes,
891
            position: position,
892
            completions: []
893
        };
894

895
        this.plugins.emit('beforeProvideCompletions', event);
74✔
896

897
        this.plugins.emit('provideCompletions', event);
74✔
898

899
        this.plugins.emit('afterProvideCompletions', event);
74✔
900

901
        return event.completions;
74✔
902
    }
903

904
    /**
905
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
906
     */
907
    public getWorkspaceSymbols() {
908
        const results = Object.keys(this.files).map(key => {
4✔
909
            const file = this.files[key];
8✔
910
            if (isBrsFile(file)) {
8!
911
                return file.getWorkspaceSymbols();
8✔
912
            }
913
            return [];
×
914
        });
915
        return util.flatMap(results, c => c);
8✔
916
    }
917

918
    /**
919
     * Given a position in a file, if the position is sitting on some type of identifier,
920
     * go to the definition of that identifier (where this thing was first defined)
921
     */
922
    public getDefinition(srcPath: string, position: Position): Location[] {
923
        let file = this.getFile(srcPath);
13✔
924
        if (!file) {
13!
925
            return [];
×
926
        }
927

928
        const event: ProvideDefinitionEvent = {
13✔
929
            program: this,
930
            file: file,
931
            position: position,
932
            definitions: []
933
        };
934

935
        this.plugins.emit('beforeProvideDefinition', event);
13✔
936
        this.plugins.emit('provideDefinition', event);
13✔
937
        this.plugins.emit('afterProvideDefinition', event);
13✔
938
        return event.definitions;
13✔
939
    }
940

941
    /**
942
     * Get hover information for a file and position
943
     */
944
    public getHover(srcPath: string, position: Position): Hover[] {
945
        let file = this.getFile(srcPath);
30✔
946
        let result: Hover[];
947
        if (file) {
30!
948
            const event = {
30✔
949
                program: this,
950
                file: file,
951
                position: position,
952
                scopes: this.getScopesForFile(file),
953
                hovers: []
954
            } as ProvideHoverEvent;
955
            this.plugins.emit('beforeProvideHover', event);
30✔
956
            this.plugins.emit('provideHover', event);
30✔
957
            this.plugins.emit('afterProvideHover', event);
30✔
958
            result = event.hovers;
30✔
959
        }
960

961
        return result ?? [];
30!
962
    }
963

964
    /**
965
     * Compute code actions for the given file and range
966
     */
967
    public getCodeActions(srcPath: string, range: Range) {
968
        const codeActions = [] as CodeAction[];
11✔
969
        const file = this.getFile(srcPath);
11✔
970
        if (file) {
11✔
971
            const diagnostics = this
10✔
972
                //get all current diagnostics (filtered by diagnostic filters)
973
                .getDiagnostics()
974
                //only keep diagnostics related to this file
975
                .filter(x => x.file === file)
26✔
976
                //only keep diagnostics that touch this range
977
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
10✔
978

979
            const scopes = this.getScopesForFile(file);
10✔
980

981
            this.plugins.emit('onGetCodeActions', {
10✔
982
                program: this,
983
                file: file,
984
                range: range,
985
                diagnostics: diagnostics,
986
                scopes: scopes,
987
                codeActions: codeActions
988
            });
989
        }
990
        return codeActions;
11✔
991
    }
992

993
    /**
994
     * Get semantic tokens for the specified file
995
     */
996
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
997
        const file = this.getFile(srcPath);
16✔
998
        if (file) {
16!
999
            const result = [] as SemanticToken[];
16✔
1000
            this.plugins.emit('onGetSemanticTokens', {
16✔
1001
                program: this,
1002
                file: file,
1003
                scopes: this.getScopesForFile(file),
1004
                semanticTokens: result
1005
            });
1006
            return result;
16✔
1007
        }
1008
    }
1009

1010
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1011
        let file: BrsFile = this.getFile(filepath);
184✔
1012
        if (!file || !isBrsFile(file)) {
184✔
1013
            return [];
3✔
1014
        }
1015
        let callExpressionInfo = new CallExpressionInfo(file, position);
181✔
1016
        let signatureHelpUtil = new SignatureHelpUtil();
181✔
1017
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
181✔
1018
    }
1019

1020
    public getReferences(srcPath: string, position: Position): Location[] {
1021
        //find the file
1022
        let file = this.getFile(srcPath);
4✔
1023
        if (!file) {
4!
1024
            return null;
×
1025
        }
1026

1027
        const event: ProvideReferencesEvent = {
4✔
1028
            program: this,
1029
            file: file,
1030
            position: position,
1031
            references: []
1032
        };
1033

1034
        this.plugins.emit('beforeProvideReferences', event);
4✔
1035
        this.plugins.emit('provideReferences', event);
4✔
1036
        this.plugins.emit('afterProvideReferences', event);
4✔
1037

1038
        return event.references;
4✔
1039
    }
1040

1041
    /**
1042
     * Get a list of all script imports, relative to the specified pkgPath
1043
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1044
     */
1045
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1046
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1047

1048
        let result = [] as CompletionItem[];
3✔
1049
        /**
1050
         * hashtable to prevent duplicate results
1051
         */
1052
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1053

1054
        //restrict to only .brs files
1055
        for (let key in this.files) {
3✔
1056
            let file = this.files[key];
4✔
1057
            if (
4✔
1058
                //is a BrightScript or BrighterScript file
1059
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1060
                //this file is not the current file
1061
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1062
            ) {
1063
                //add the relative path
1064
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1065
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1066
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1067
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1068
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1069
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1070

1071
                    result.push({
3✔
1072
                        label: relativePath,
1073
                        detail: file.srcPath,
1074
                        kind: CompletionItemKind.File,
1075
                        textEdit: {
1076
                            newText: relativePath,
1077
                            range: scriptImport.filePathRange
1078
                        }
1079
                    });
1080

1081
                    //add the absolute path
1082
                    result.push({
3✔
1083
                        label: filePkgPath,
1084
                        detail: file.srcPath,
1085
                        kind: CompletionItemKind.File,
1086
                        textEdit: {
1087
                            newText: filePkgPath,
1088
                            range: scriptImport.filePathRange
1089
                        }
1090
                    });
1091
                }
1092
            }
1093
        }
1094
        return result;
3✔
1095
    }
1096

1097
    /**
1098
     * Transpile a single file and get the result as a string.
1099
     * This does not write anything to the file system.
1100
     *
1101
     * This should only be called by `LanguageServer`.
1102
     * Internal usage should call `_getTranspiledFileContents` instead.
1103
     * @param filePath can be a srcPath or a destPath
1104
     */
1105
    public async getTranspiledFileContents(filePath: string) {
1106
        let fileMap = await rokuDeploy.getFilePaths(this.options.files, this.options.rootDir);
4✔
1107
        //remove files currently loaded in the program, we will transpile those instead (even if just for source maps)
1108
        let filteredFileMap = [] as FileObj[];
4✔
1109
        for (let fileEntry of fileMap) {
4✔
1110
            if (this.hasFile(fileEntry.src) === false) {
2!
1111
                filteredFileMap.push(fileEntry);
×
1112
            }
1113
        }
1114
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1115
        const result = this._getTranspiledFileContents(
4✔
1116
            this.getFile(filePath)
1117
        );
1118
        this.afterProgramTranspile(entries, astEditor);
4✔
1119
        return result;
4✔
1120
    }
1121

1122
    /**
1123
     * Internal function used to transpile files.
1124
     * This does not write anything to the file system
1125
     */
1126
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1127
        const editor = new AstEditor();
269✔
1128

1129
        this.plugins.emit('beforeFileTranspile', {
269✔
1130
            program: this,
1131
            file: file,
1132
            outputPath: outputPath,
1133
            editor: editor
1134
        });
1135

1136
        //if we have any edits, assume the file needs to be transpiled
1137
        if (editor.hasChanges) {
269✔
1138
            //use the `editor` because it'll track the previous value for us and revert later on
1139
            editor.setProperty(file, 'needsTranspiled', true);
33✔
1140
        }
1141

1142
        //transpile the file
1143
        const result = file.transpile();
269✔
1144

1145
        //generate the typedef if enabled
1146
        let typedef: string;
1147
        if (isBrsFile(file) && this.options.emitDefinitions) {
269✔
1148
            typedef = file.getTypedef();
2✔
1149
        }
1150

1151
        const event: AfterFileTranspileEvent = {
269✔
1152
            program: this,
1153
            file: file,
1154
            outputPath: outputPath,
1155
            editor: editor,
1156
            code: result.code,
1157
            map: result.map,
1158
            typedef: typedef
1159
        };
1160
        this.plugins.emit('afterFileTranspile', event);
269✔
1161

1162
        //undo all `editor` edits that may have been applied to this file.
1163
        editor.undoAll();
269✔
1164

1165
        return {
269✔
1166
            srcPath: file.srcPath,
1167
            pkgPath: file.pkgPath,
1168
            code: event.code,
1169
            map: event.map,
1170
            typedef: event.typedef
1171
        };
1172
    }
1173

1174
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1175
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1176
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1177
            collection[s`${entry.src}`] = entry;
18✔
1178
            return collection;
18✔
1179
        }, {});
1180

1181
        const getOutputPath = (file: BscFile) => {
37✔
1182
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1183
            if (!filePathObj) {
77✔
1184
                //this file has been added in-memory, from a plugin, for example
1185
                filePathObj = {
50✔
1186
                    //add an interpolated src path (since it doesn't actually exist in memory)
1187
                    src: `bsc:/${file.pkgPath}`,
1188
                    dest: file.pkgPath
1189
                };
1190
            }
1191
            //replace the file extension
1192
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1193
            //prepend the staging folder path
1194
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1195
            return outputPath;
77✔
1196
        };
1197

1198
        const entries = Object.values(this.files).map(file => {
37✔
1199
            return {
39✔
1200
                file: file,
1201
                outputPath: getOutputPath(file)
1202
            };
1203
            //sort the entries to make transpiling more deterministic
1204
        }).sort((a, b) => {
1205
            return a.file.srcPath < b.file.srcPath ? -1 : 1;
8✔
1206
        });
1207

1208
        const astEditor = new AstEditor();
37✔
1209

1210
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1211
        return {
37✔
1212
            entries: entries,
1213
            getOutputPath: getOutputPath,
1214
            astEditor: astEditor
1215
        };
1216
    }
1217

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

1221
        const processedFiles = new Set<string>();
32✔
1222

1223
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1224
            //find the file in the program
1225
            const file = this.getFile(srcPath);
35✔
1226
            //mark this file as processed so we don't process it more than once
1227
            processedFiles.add(outputPath?.toLowerCase());
35!
1228

1229
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1230
                //skip transpiling typedef files
1231
                if (isBrsFile(file) && file.isTypedef) {
34✔
1232
                    return;
1✔
1233
                }
1234

1235
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1236

1237
                //make sure the full dir path exists
1238
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1239

1240
                if (await fsExtra.pathExists(outputPath)) {
33!
1241
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1242
                }
1243
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1244
                await Promise.all([
33✔
1245
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1246
                    writeMapPromise
1247
                ]);
1248

1249
                if (fileTranspileResult.typedef) {
33✔
1250
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1251
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1252
                }
1253
            }
1254
        };
1255

1256
        let promises = entries.map(async (entry) => {
32✔
1257
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
33!
1258
        });
1259

1260
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1261
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1262
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1263
        }
1264
        await Promise.all(promises);
32✔
1265

1266
        //transpile any new files that plugins added since the start of this transpile process
1267
        do {
32✔
1268
            promises = [];
33✔
1269
            for (const key in this.files) {
33✔
1270
                const file = this.files[key];
38✔
1271
                //this is a new file
1272
                const outputPath = getOutputPath(file);
38✔
1273
                if (!processedFiles.has(outputPath?.toLowerCase())) {
38!
1274
                    promises.push(
2✔
1275
                        transpileFile(file?.srcPath, outputPath)
6!
1276
                    );
1277
                }
1278
            }
1279
            if (promises.length > 0) {
33✔
1280
                this.logger.info(`Transpiling ${promises.length} new files`);
1✔
1281
                await Promise.all(promises);
1✔
1282
            }
1283
        }
1284
        while (promises.length > 0);
1285
        this.afterProgramTranspile(entries, astEditor);
32✔
1286
    }
1287

1288
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1289
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1290
        astEditor.undoAll();
36✔
1291
    }
1292

1293
    /**
1294
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1295
     */
1296
    public findFilesForFunction(functionName: string) {
1297
        const files = [] as BscFile[];
7✔
1298
        const lowerFunctionName = functionName.toLowerCase();
7✔
1299
        //find every file with this function defined
1300
        for (const file of Object.values(this.files)) {
7✔
1301
            if (isBrsFile(file)) {
25✔
1302
                //TODO handle namespace-relative function calls
1303
                //if the file has a function with this name
1304
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1305
                    files.push(file);
2✔
1306
                }
1307
            }
1308
        }
1309
        return files;
7✔
1310
    }
1311

1312
    /**
1313
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1314
     */
1315
    public findFilesForClass(className: string) {
1316
        const files = [] as BscFile[];
7✔
1317
        const lowerClassName = className.toLowerCase();
7✔
1318
        //find every file with this class defined
1319
        for (const file of Object.values(this.files)) {
7✔
1320
            if (isBrsFile(file)) {
25✔
1321
                //TODO handle namespace-relative classes
1322
                //if the file has a function with this name
1323
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1324
                    files.push(file);
1✔
1325
                }
1326
            }
1327
        }
1328
        return files;
7✔
1329
    }
1330

1331
    public findFilesForNamespace(name: string) {
1332
        const files = [] as BscFile[];
7✔
1333
        const lowerName = name.toLowerCase();
7✔
1334
        //find every file with this class defined
1335
        for (const file of Object.values(this.files)) {
7✔
1336
            if (isBrsFile(file)) {
25✔
1337
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1338
                    const namespaceName = x.name.toLowerCase();
7✔
1339
                    return (
7✔
1340
                        //the namespace name matches exactly
1341
                        namespaceName === lowerName ||
9✔
1342
                        //the full namespace starts with the name (honoring the part boundary)
1343
                        namespaceName.startsWith(lowerName + '.')
1344
                    );
1345
                })) {
1346
                    files.push(file);
6✔
1347
                }
1348
            }
1349
        }
1350
        return files;
7✔
1351
    }
1352

1353
    public findFilesForEnum(name: string) {
1354
        const files = [] as BscFile[];
8✔
1355
        const lowerName = name.toLowerCase();
8✔
1356
        //find every file with this class defined
1357
        for (const file of Object.values(this.files)) {
8✔
1358
            if (isBrsFile(file)) {
26✔
1359
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1360
                    files.push(file);
1✔
1361
                }
1362
            }
1363
        }
1364
        return files;
8✔
1365
    }
1366

1367
    private _manifest: Map<string, string>;
1368

1369
    /**
1370
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1371
     * @param parsedManifest The manifest map to read from and modify
1372
     */
1373
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1374
        // Lift the bs_consts defined in the manifest
1375
        let bsConsts = getBsConst(parsedManifest, false);
11✔
1376

1377
        // Override or delete any bs_consts defined in the bs config
1378
        for (const key in this.options?.manifest?.bs_const) {
11!
1379
            const value = this.options.manifest.bs_const[key];
3✔
1380
            if (value === null) {
3✔
1381
                bsConsts.delete(key);
1✔
1382
            } else {
1383
                bsConsts.set(key, value);
2✔
1384
            }
1385
        }
1386

1387
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1388
        let constString = '';
11✔
1389
        for (const [key, value] of bsConsts) {
11✔
1390
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1391
        }
1392

1393
        // Set the updated bs_const value
1394
        parsedManifest.set('bs_const', constString);
11✔
1395
    }
1396

1397
    /**
1398
     * Try to find and load the manifest into memory
1399
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1400
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1401
     */
1402
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
830✔
1403
        //if we already have a manifest instance, and should not replace...then don't replace
1404
        if (!replaceIfAlreadyLoaded && this._manifest) {
834!
1405
            return;
×
1406
        }
1407
        let manifestPath = manifestFileObj
834✔
1408
            ? manifestFileObj.src
834✔
1409
            : path.join(this.options.rootDir, 'manifest');
1410

1411
        try {
834✔
1412
            // we only load this manifest once, so do it sync to improve speed downstream
1413
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
834✔
1414
            const parsedManifest = parseManifest(contents);
11✔
1415
            this.buildBsConstsIntoParsedManifest(parsedManifest);
11✔
1416
            this._manifest = parsedManifest;
11✔
1417
        } catch (e) {
1418
            this._manifest = new Map();
823✔
1419
        }
1420
    }
1421

1422
    /**
1423
     * Get a map of the manifest information
1424
     */
1425
    public getManifest() {
1426
        if (!this._manifest) {
1,022✔
1427
            this.loadManifest();
829✔
1428
        }
1429
        return this._manifest;
1,022✔
1430
    }
1431

1432
    public dispose() {
1433
        this.plugins.emit('beforeProgramDispose', { program: this });
900✔
1434

1435
        for (let filePath in this.files) {
900✔
1436
            this.files[filePath].dispose();
989✔
1437
        }
1438
        for (let name in this.scopes) {
900✔
1439
            this.scopes[name].dispose();
1,808✔
1440
        }
1441
        this.globalScope.dispose();
900✔
1442
        this.dependencyGraph.dispose();
900✔
1443
    }
1444
}
1445

1446
export interface FileTranspileResult {
1447
    srcPath: string;
1448
    pkgPath: string;
1449
    code: string;
1450
    map: SourceMapGenerator;
1451
    typedef: string;
1452
}
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