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

rokucommunity / brighterscript / #13774

30 Jan 2024 04:02PM UTC coverage: 88.056% (-0.1%) from 88.176%
#13774

push

TwitchBronBron
0.65.19

5819 of 7088 branches covered (82.1%)

Branch coverage included in aggregate %.

8646 of 9339 relevant lines covered (92.58%)

1683.59 hits per line

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

93.03
/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 } 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,073✔
68
        this.logger = logger || new Logger(options.logLevel as LogLevel);
1,073✔
69
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,073✔
70

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

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

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

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

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

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

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

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

161
    protected addScope(scope: Scope) {
162
        this.scopes[scope.name] = scope;
974✔
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,073✔
172

173
    /**
174
     * Get the component with the specified name
175
     */
176
    public getComponent(componentName: string) {
177
        if (componentName) {
483✔
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];
465✔
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();
204✔
191
        if (!this.components[key]) {
204✔
192
            this.components[key] = [];
192✔
193
        }
194
        this.components[key].push({
204✔
195
            file: xmlFile,
196
            scope: scope
197
        });
198
        this.components[key].sort((a, b) => {
204✔
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]);
204✔
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++) {
215✔
234
            const { file, scope } = components[i];
210✔
235

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

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

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

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

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

299
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
644✔
300
            return filteredDiagnostics;
644✔
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,178✔
314
        return !!this.getFile(filePath, normalizePath);
1,178✔
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);
7✔
340
    }
341

342
    /**
343
     * Find the scope for the specified component
344
     */
345
    public getComponentScope(componentName: string) {
346
        return this.getComponent(componentName)?.scope;
138✔
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,160✔
354
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,160✔
355
        return file;
1,160✔
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,163✔
402

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

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

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

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

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

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

438
                file = brsFile;
956✔
439

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

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

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

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

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

468
                file = xmlFile;
204✔
469

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

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

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

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

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

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

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

543
        return {
1,170✔
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,169✔
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,073✔
563
            const sourceScope = new Scope('source', this, 'scope:source');
770✔
564
            sourceScope.attachDependencyGraph(this.dependencyGraph);
770✔
565
            this.addScope(sourceScope);
770✔
566
            this.plugins.emit('afterScopeCreate', sourceScope);
770✔
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;
345✔
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'], () => {
657✔
664
            this.diagnostics = [];
657✔
665
            this.plugins.emit('beforeProgramValidate', this);
657✔
666

667
            //validate every file
668
            for (const file of Object.values(this.files)) {
657✔
669
                //for every unvalidated file, validate it
670
                if (!file.isValidated) {
834✔
671
                    this.plugins.emit('beforeFileValidate', {
806✔
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', {
806✔
678
                        program: this,
679
                        file: file
680
                    });
681
                    //call file.validate() IF the file has that function defined
682
                    file.validate?.();
806!
683
                    file.isValidated = true;
806✔
684

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

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

698
            this.detectDuplicateComponentNames();
657✔
699

700
            this.plugins.emit('afterProgramValidate', this);
657✔
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) => {
657✔
709
            const file = this.files[filePath];
834✔
710
            //if this is an XmlFile, and it has a valid `componentName` property
711
            if (isXmlFile(file) && file.componentName?.text) {
834✔
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;
834✔
719
        }, {});
720

721
        for (let name in componentsByName) {
657✔
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,806✔
763
        if (typeof filePath !== 'string') {
6,090✔
764
            return undefined;
1,381✔
765
        } else if (path.isAbsolute(filePath)) {
4,709✔
766
            return this.files[
2,359✔
767
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,359!
768
            ] as T;
769
        } else {
770
            return this.pkgMap[
2,350✔
771
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,350!
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;
626✔
783

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

789
                if (scope.hasFile(resolvedFile)) {
1,240✔
790
                    result.push(scope);
589✔
791
                }
792
            }
793
        }
794
        return result;
626✔
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,757✔
802
            let scope = this.scopes[key];
4,196✔
803

804
            if (scope.hasFile(file)) {
4,196✔
805
                return scope;
1,724✔
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) {
923
        let file = this.getFile(srcPath);
9✔
924
        if (!file) {
9!
925
            return [];
×
926
        }
927

928
        if (isBrsFile(file)) {
9!
929
            return file.getDefinition(position);
9✔
930
        } else {
931
            let results = [] as Location[];
×
932
            const scopes = this.getScopesForFile(file);
×
933
            for (const scope of scopes) {
×
934
                results = results.concat(...scope.getDefinition(file, position));
×
935
            }
936
            return results;
×
937
        }
938
    }
939

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

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

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

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

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

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

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

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

1026
        return file.getReferences(position);
3✔
1027
    }
1028

1029
    /**
1030
     * Get a list of all script imports, relative to the specified pkgPath
1031
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1032
     */
1033
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1034
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1035

1036
        let result = [] as CompletionItem[];
3✔
1037
        /**
1038
         * hashtable to prevent duplicate results
1039
         */
1040
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1041

1042
        //restrict to only .brs files
1043
        for (let key in this.files) {
3✔
1044
            let file = this.files[key];
4✔
1045
            if (
4✔
1046
                //is a BrightScript or BrighterScript file
1047
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1048
                //this file is not the current file
1049
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1050
            ) {
1051
                //add the relative path
1052
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1053
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1054
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1055
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1056
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1057
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1058

1059
                    result.push({
3✔
1060
                        label: relativePath,
1061
                        detail: file.srcPath,
1062
                        kind: CompletionItemKind.File,
1063
                        textEdit: {
1064
                            newText: relativePath,
1065
                            range: scriptImport.filePathRange
1066
                        }
1067
                    });
1068

1069
                    //add the absolute path
1070
                    result.push({
3✔
1071
                        label: filePkgPath,
1072
                        detail: file.srcPath,
1073
                        kind: CompletionItemKind.File,
1074
                        textEdit: {
1075
                            newText: filePkgPath,
1076
                            range: scriptImport.filePathRange
1077
                        }
1078
                    });
1079
                }
1080
            }
1081
        }
1082
        return result;
3✔
1083
    }
1084

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

1110
    /**
1111
     * Internal function used to transpile files.
1112
     * This does not write anything to the file system
1113
     */
1114
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1115
        const editor = new AstEditor();
267✔
1116

1117
        this.plugins.emit('beforeFileTranspile', {
267✔
1118
            program: this,
1119
            file: file,
1120
            outputPath: outputPath,
1121
            editor: editor
1122
        });
1123

1124
        //if we have any edits, assume the file needs to be transpiled
1125
        if (editor.hasChanges) {
267✔
1126
            //use the `editor` because it'll track the previous value for us and revert later on
1127
            editor.setProperty(file, 'needsTranspiled', true);
33✔
1128
        }
1129

1130
        //transpile the file
1131
        const result = file.transpile();
267✔
1132

1133
        //generate the typedef if enabled
1134
        let typedef: string;
1135
        if (isBrsFile(file) && this.options.emitDefinitions) {
267✔
1136
            typedef = file.getTypedef();
2✔
1137
        }
1138

1139
        const event: AfterFileTranspileEvent = {
267✔
1140
            program: this,
1141
            file: file,
1142
            outputPath: outputPath,
1143
            editor: editor,
1144
            code: result.code,
1145
            map: result.map,
1146
            typedef: typedef
1147
        };
1148
        this.plugins.emit('afterFileTranspile', event);
267✔
1149

1150
        //undo all `editor` edits that may have been applied to this file.
1151
        editor.undoAll();
267✔
1152

1153
        return {
267✔
1154
            srcPath: file.srcPath,
1155
            pkgPath: file.pkgPath,
1156
            code: event.code,
1157
            map: event.map,
1158
            typedef: event.typedef
1159
        };
1160
    }
1161

1162
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1163
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1164
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1165
            collection[s`${entry.src}`] = entry;
18✔
1166
            return collection;
18✔
1167
        }, {});
1168

1169
        const getOutputPath = (file: BscFile) => {
37✔
1170
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1171
            if (!filePathObj) {
77✔
1172
                //this file has been added in-memory, from a plugin, for example
1173
                filePathObj = {
50✔
1174
                    //add an interpolated src path (since it doesn't actually exist in memory)
1175
                    src: `bsc:/${file.pkgPath}`,
1176
                    dest: file.pkgPath
1177
                };
1178
            }
1179
            //replace the file extension
1180
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1181
            //prepend the staging folder path
1182
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1183
            return outputPath;
77✔
1184
        };
1185

1186
        const entries = Object.values(this.files).map(file => {
37✔
1187
            return {
39✔
1188
                file: file,
1189
                outputPath: getOutputPath(file)
1190
            };
1191
            //sort the entries to make transpiling more deterministic
1192
        }).sort((a, b) => {
1193
            return a.file.srcPath < b.file.srcPath ? -1 : 1;
8✔
1194
        });
1195

1196
        const astEditor = new AstEditor();
37✔
1197

1198
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1199
        return {
37✔
1200
            entries: entries,
1201
            getOutputPath: getOutputPath,
1202
            astEditor: astEditor
1203
        };
1204
    }
1205

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

1209
        const processedFiles = new Set<string>();
32✔
1210

1211
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1212
            //find the file in the program
1213
            const file = this.getFile(srcPath);
35✔
1214
            //mark this file as processed so we don't process it more than once
1215
            processedFiles.add(outputPath?.toLowerCase());
35!
1216

1217
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1218
                //skip transpiling typedef files
1219
                if (isBrsFile(file) && file.isTypedef) {
34✔
1220
                    return;
1✔
1221
                }
1222

1223
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1224

1225
                //make sure the full dir path exists
1226
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1227

1228
                if (await fsExtra.pathExists(outputPath)) {
33!
1229
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1230
                }
1231
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1232
                await Promise.all([
33✔
1233
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1234
                    writeMapPromise
1235
                ]);
1236

1237
                if (fileTranspileResult.typedef) {
33✔
1238
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1239
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1240
                }
1241
            }
1242
        };
1243

1244
        let promises = entries.map(async (entry) => {
32✔
1245
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
33!
1246
        });
1247

1248
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1249
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1250
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1251
        }
1252
        await Promise.all(promises);
32✔
1253

1254
        //transpile any new files that plugins added since the start of this transpile process
1255
        do {
32✔
1256
            promises = [];
33✔
1257
            for (const key in this.files) {
33✔
1258
                const file = this.files[key];
38✔
1259
                //this is a new file
1260
                const outputPath = getOutputPath(file);
38✔
1261
                if (!processedFiles.has(outputPath?.toLowerCase())) {
38!
1262
                    promises.push(
2✔
1263
                        transpileFile(file?.srcPath, outputPath)
6!
1264
                    );
1265
                }
1266
            }
1267
            if (promises.length > 0) {
33✔
1268
                this.logger.info(`Transpiling ${promises.length} new files`);
1✔
1269
                await Promise.all(promises);
1✔
1270
            }
1271
        }
1272
        while (promises.length > 0);
1273
        this.afterProgramTranspile(entries, astEditor);
32✔
1274
    }
1275

1276
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1277
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1278
        astEditor.undoAll();
36✔
1279
    }
1280

1281
    /**
1282
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1283
     */
1284
    public findFilesForFunction(functionName: string) {
1285
        const files = [] as BscFile[];
7✔
1286
        const lowerFunctionName = functionName.toLowerCase();
7✔
1287
        //find every file with this function defined
1288
        for (const file of Object.values(this.files)) {
7✔
1289
            if (isBrsFile(file)) {
25✔
1290
                //TODO handle namespace-relative function calls
1291
                //if the file has a function with this name
1292
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1293
                    files.push(file);
2✔
1294
                }
1295
            }
1296
        }
1297
        return files;
7✔
1298
    }
1299

1300
    /**
1301
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1302
     */
1303
    public findFilesForClass(className: string) {
1304
        const files = [] as BscFile[];
7✔
1305
        const lowerClassName = className.toLowerCase();
7✔
1306
        //find every file with this class defined
1307
        for (const file of Object.values(this.files)) {
7✔
1308
            if (isBrsFile(file)) {
25✔
1309
                //TODO handle namespace-relative classes
1310
                //if the file has a function with this name
1311
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1312
                    files.push(file);
1✔
1313
                }
1314
            }
1315
        }
1316
        return files;
7✔
1317
    }
1318

1319
    public findFilesForNamespace(name: string) {
1320
        const files = [] as BscFile[];
7✔
1321
        const lowerName = name.toLowerCase();
7✔
1322
        //find every file with this class defined
1323
        for (const file of Object.values(this.files)) {
7✔
1324
            if (isBrsFile(file)) {
25✔
1325
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1326
                    const namespaceName = x.name.toLowerCase();
7✔
1327
                    return (
7✔
1328
                        //the namespace name matches exactly
1329
                        namespaceName === lowerName ||
9✔
1330
                        //the full namespace starts with the name (honoring the part boundary)
1331
                        namespaceName.startsWith(lowerName + '.')
1332
                    );
1333
                })) {
1334
                    files.push(file);
6✔
1335
                }
1336
            }
1337
        }
1338
        return files;
7✔
1339
    }
1340

1341
    public findFilesForEnum(name: string) {
1342
        const files = [] as BscFile[];
8✔
1343
        const lowerName = name.toLowerCase();
8✔
1344
        //find every file with this class defined
1345
        for (const file of Object.values(this.files)) {
8✔
1346
            if (isBrsFile(file)) {
26✔
1347
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1348
                    files.push(file);
1✔
1349
                }
1350
            }
1351
        }
1352
        return files;
8✔
1353
    }
1354

1355
    private _manifest: Map<string, string>;
1356

1357
    /**
1358
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1359
     * @param parsedManifest The manifest map to read from and modify
1360
     */
1361
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1362
        // Lift the bs_consts defined in the manifest
1363
        let bsConsts = getBsConst(parsedManifest, false);
11✔
1364

1365
        // Override or delete any bs_consts defined in the bs config
1366
        for (const key in this.options?.manifest?.bs_const) {
11!
1367
            const value = this.options.manifest.bs_const[key];
3✔
1368
            if (value === null) {
3✔
1369
                bsConsts.delete(key);
1✔
1370
            } else {
1371
                bsConsts.set(key, value);
2✔
1372
            }
1373
        }
1374

1375
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1376
        let constString = '';
11✔
1377
        for (const [key, value] of bsConsts) {
11✔
1378
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1379
        }
1380

1381
        // Set the updated bs_const value
1382
        parsedManifest.set('bs_const', constString);
11✔
1383
    }
1384

1385
    /**
1386
     * Try to find and load the manifest into memory
1387
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1388
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1389
     */
1390
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
819✔
1391
        //if we already have a manifest instance, and should not replace...then don't replace
1392
        if (!replaceIfAlreadyLoaded && this._manifest) {
823!
1393
            return;
×
1394
        }
1395
        let manifestPath = manifestFileObj
823✔
1396
            ? manifestFileObj.src
823✔
1397
            : path.join(this.options.rootDir, 'manifest');
1398

1399
        try {
823✔
1400
            // we only load this manifest once, so do it sync to improve speed downstream
1401
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
823✔
1402
            const parsedManifest = parseManifest(contents);
11✔
1403
            this.buildBsConstsIntoParsedManifest(parsedManifest);
11✔
1404
            this._manifest = parsedManifest;
11✔
1405
        } catch (e) {
1406
            this._manifest = new Map();
812✔
1407
        }
1408
    }
1409

1410
    /**
1411
     * Get a map of the manifest information
1412
     */
1413
    public getManifest() {
1414
        if (!this._manifest) {
1,010✔
1415
            this.loadManifest();
818✔
1416
        }
1417
        return this._manifest;
1,010✔
1418
    }
1419

1420
    public dispose() {
1421
        this.plugins.emit('beforeProgramDispose', { program: this });
888✔
1422

1423
        for (let filePath in this.files) {
888✔
1424
            this.files[filePath].dispose();
976✔
1425
        }
1426
        for (let name in this.scopes) {
888✔
1427
            this.scopes[name].dispose();
1,784✔
1428
        }
1429
        this.globalScope.dispose();
888✔
1430
        this.dependencyGraph.dispose();
888✔
1431
    }
1432
}
1433

1434
export interface FileTranspileResult {
1435
    srcPath: string;
1436
    pkgPath: string;
1437
    code: string;
1438
    map: SourceMapGenerator;
1439
    typedef: string;
1440
}
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