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

rokucommunity / brighterscript / #15649

28 Apr 2026 04:49PM UTC coverage: 89.038%. Remained the same
#15649

push

web-flow
Merge cfb029364 into 7d61e4cab

8352 of 9878 branches covered (84.55%)

Branch coverage included in aggregate %.

151 of 161 new or added lines in 3 files covered. (93.79%)

60 existing lines in 3 files now uncovered.

10581 of 11386 relevant lines covered (92.93%)

2011.51 hits per line

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

94.31
/src/Program.ts
1
import * as assert from 'assert';
1✔
2
import * as fsExtra from 'fs-extra';
1✔
3
import * as path from 'path';
1✔
4
import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange } from 'vscode-languageserver';
5
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
1✔
6
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
7
import { Scope } from './Scope';
1✔
8
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
9
import { BrsFile } from './files/BrsFile';
1✔
10
import { XmlFile } from './files/XmlFile';
1✔
11
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent } 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 type { Logger } from './logging';
17
import { LogLevel, createLogger } from './logging';
1✔
18
import chalk from 'chalk';
1✔
19
import { globalFile } from './globalCallables';
1✔
20
import { parseManifest, getBsConst } from './preprocessor/Manifest';
1✔
21
import { URI } from 'vscode-uri';
1✔
22
import PluginInterface from './PluginInterface';
1✔
23
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
1✔
24
import type { FunctionStatement, NamespaceStatement } from './parser/Statement';
25
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
26
import { AstEditor } from './astUtils/AstEditor';
1✔
27
import type { SourceMapGenerator } from 'source-map';
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
import { Sequencer } from './common/Sequencer';
1✔
33
import { Deferred } from './deferred';
1✔
34

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

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

49
export interface TranspileObj {
50
    file: BscFile;
51
    outputPath: string;
52
}
53

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

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

73
        //inject the bsc plugin as the first plugin in the stack.
74
        this.plugins.addFirst(new BscPlugin());
1,540✔
75

76
        //normalize the root dir path
77
        this.options.rootDir = util.getRootDir(this.options);
1,540✔
78

79
        this.createGlobalScope();
1,540✔
80
    }
81

82
    public options: FinalizedBsConfig;
83
    public logger: Logger;
84

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

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

107
    private diagnosticFilterer = new DiagnosticFilterer();
1,540✔
108

109
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,540✔
110

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

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

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

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

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

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

146
    public get bslibPrefix() {
147
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
441✔
148
            return 'rokucommunity_bslib';
3✔
149
        } else {
150
            return 'bslib';
438✔
151
        }
152
    }
153

154

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

161
    /**
162
     * Reverse index from a lower-cased namespace name part to the set of `BrsFile`s that
163
     * contribute to it. Built lazily, invalidated whenever any file is added, removed,
164
     * or re-parsed (`setFile` and `removeFile` both clear it).
165
     *
166
     * Used by `ScopeNamespaceLookup` to resolve a namespace name to its contributing
167
     * files in O(1), then intersect against the scope's file set.
168
     */
169
    private _namespaceFileIndex: Map<string, Set<BrsFile>> | undefined;
170

171
    /**
172
     * Look up the set of `BrsFile`s that declare any part of the given namespace name
173
     * (lowercased). Returns `undefined` when no file contributes.
174
     */
175
    public getFilesContributingToNamespace(namespaceNameLower: string): Set<BrsFile> | undefined {
176
        if (!this._namespaceFileIndex) {
269✔
177
            this._namespaceFileIndex = this.buildNamespaceFileIndex();
172✔
178
        }
179
        return this._namespaceFileIndex.get(namespaceNameLower);
269✔
180
    }
181

182
    private buildNamespaceFileIndex(): Map<string, Set<BrsFile>> {
183
        const index = new Map<string, Set<BrsFile>>();
172✔
184
        for (const file of Object.values(this.files)) {
172✔
185
            if (isBrsFile(file)) {
224✔
186
                for (const nameLower of file.getNamespaceContributions().keys()) {
214✔
187
                    let set = index.get(nameLower);
298✔
188
                    if (!set) {
298✔
189
                        set = new Set<BrsFile>();
285✔
190
                        index.set(nameLower, set);
285✔
191
                    }
192
                    set.add(file);
298✔
193
                }
194
            }
195
        }
196
        return index;
172✔
197
    }
198

199
    /**
200
     * Invalidate the program-level namespace-to-file index. Called by `setFile` and
201
     * `removeFile`; downstream scope namespace lookups already rebuild via the
202
     * dependency-graph invalidation chain, so this only needs to drop the cached index.
203
     */
204
    public invalidateNamespaceIndex() {
205
        this._namespaceFileIndex = undefined;
1,743✔
206
    }
207

208
    private scopes = {} as Record<string, Scope>;
1,540✔
209

210
    protected addScope(scope: Scope) {
211
        this.scopes[scope.name] = scope;
1,417✔
212
    }
213

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

222
    /**
223
     * Get the component with the specified name
224
     */
225
    public getComponent(componentName: string) {
226
        if (componentName) {
644✔
227
            //return the first compoment in the list with this name
228
            //(components are ordered in this list by pkgPath to ensure consistency)
229
            return this.components[componentName.toLowerCase()]?.[0];
601✔
230
        } else {
231
            return undefined;
43✔
232
        }
233
    }
234

235
    /**
236
     * Register (or replace) the reference to a component in the component map
237
     */
238
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
239
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
261✔
240
        if (!this.components[key]) {
261✔
241
            this.components[key] = [];
249✔
242
        }
243
        this.components[key].push({
261✔
244
            file: xmlFile,
245
            scope: scope
246
        });
247
        this.components[key].sort((a, b) => {
261✔
248
            const pathA = a.file.pkgPath.toLowerCase();
5✔
249
            const pathB = b.file.pkgPath.toLowerCase();
5✔
250
            if (pathA < pathB) {
5✔
251
                return -1;
1✔
252
            } else if (pathA > pathB) {
4!
253
                return 1;
4✔
254
            }
UNCOV
255
            return 0;
×
256
        });
257
        this.syncComponentDependencyGraph(this.components[key]);
261✔
258
    }
259

260
    /**
261
     * Remove the specified component from the components map
262
     */
263
    private unregisterComponent(xmlFile: XmlFile) {
264
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
11✔
265
        const arr = this.components[key] || [];
11!
266
        for (let i = 0; i < arr.length; i++) {
11✔
267
            if (arr[i].file === xmlFile) {
11!
268
                arr.splice(i, 1);
11✔
269
                break;
11✔
270
            }
271
        }
272
        this.syncComponentDependencyGraph(arr);
11✔
273
    }
274

275
    /**
276
     * re-attach the dependency graph with a new key for any component who changed
277
     * their position in their own named array (only matters when there are multiple
278
     * components with the same name)
279
     */
280
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
281
        //reattach every dependency graph
282
        for (let i = 0; i < components.length; i++) {
272✔
283
            const { file, scope } = components[i];
267✔
284

285
            //attach (or re-attach) the dependencyGraph for every component whose position changed
286
            if (file.dependencyGraphIndex !== i) {
267✔
287
                file.dependencyGraphIndex = i;
263✔
288
                file.attachDependencyGraph(this.dependencyGraph);
263✔
289
                scope.attachDependencyGraph(this.dependencyGraph);
263✔
290
            }
291
        }
292
    }
293

294
    /**
295
     * Get a list of all files that are included in the project but are not referenced
296
     * by any scope in the program.
297
     */
298
    public getUnreferencedFiles() {
299
        let result = [] as File[];
1,026✔
300
        for (let filePath in this.files) {
1,026✔
301
            let file = this.files[filePath];
1,271✔
302
            //is this file part of a scope
303
            if (!this.getFirstScopeForFile(file)) {
1,271✔
304
                //no scopes reference this file. add it to the list
305
                result.push(file);
45✔
306
            }
307
        }
308
        return result;
1,026✔
309
    }
310

311
    /**
312
     * Get the list of errors for the entire program. It's calculated on the fly
313
     * by walking through every file, so call this sparingly.
314
     */
315
    public getDiagnostics() {
316
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
1,026✔
317

318
            let diagnostics = [...this.diagnostics];
1,026✔
319

320
            //get the diagnostics from all scopes
321
            for (let scopeName in this.scopes) {
1,026✔
322
                let scope = this.scopes[scopeName];
2,088✔
323
                diagnostics.push(
2,088✔
324
                    ...scope.getDiagnostics()
325
                );
326
            }
327

328
            //get the diagnostics from all unreferenced files
329
            let unreferencedFiles = this.getUnreferencedFiles();
1,026✔
330
            for (let file of unreferencedFiles) {
1,026✔
331
                diagnostics.push(
45✔
332
                    ...file.getDiagnostics()
333
                );
334
            }
335
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
1,026✔
336
                //filter out diagnostics based on our diagnostic filters
337
                let finalDiagnostics = this.diagnosticFilterer.filter({
1,026✔
338
                    ...this.options,
339
                    rootDir: this.options.rootDir
340
                }, diagnostics);
341
                return finalDiagnostics;
1,026✔
342
            });
343

344
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,026✔
345
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,026✔
346
            });
347

348
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
1,026✔
349
            return filteredDiagnostics;
1,026✔
350
        });
351
    }
352

353
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
354
        this.diagnostics.push(...diagnostics);
45✔
355
    }
356

357
    /**
358
     * Determine if the specified file is loaded in this program right now.
359
     * @param filePath the absolute or relative path to the file
360
     * @param normalizePath should the provided path be normalized before use
361
     */
362
    public hasFile(filePath: string, normalizePath = true) {
1,624✔
363
        return !!this.getFile(filePath, normalizePath);
1,624✔
364
    }
365

366
    public getPkgPath(...args: any[]): any { //eslint-disable-line
UNCOV
367
        throw new Error('Not implemented');
×
368
    }
369

370
    /**
371
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
372
     */
373
    public getScopeByName(scopeName: string): Scope | undefined {
374
        if (!scopeName) {
35!
UNCOV
375
            return undefined;
×
376
        }
377
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
378
        //so it's safe to run the standardizePkgPath method
379
        scopeName = s`${scopeName}`;
35✔
380
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
86✔
381
        return this.scopes[key!];
35✔
382
    }
383

384
    /**
385
     * Return all scopes
386
     */
387
    public getScopes() {
388
        return Object.values(this.scopes);
9✔
389
    }
390

391
    /**
392
     * Find the scope for the specified component
393
     */
394
    public getComponentScope(componentName: string) {
395
        return this.getComponent(componentName)?.scope;
189✔
396
    }
397

398
    /**
399
     * Update internal maps with this file reference
400
     */
401
    private assignFile<T extends BscFile = BscFile>(file: T) {
402
        this.files[file.srcPath.toLowerCase()] = file;
1,586✔
403
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,586✔
404
        return file;
1,586✔
405
    }
406

407
    /**
408
     * Remove this file from internal maps
409
     */
410
    private unassignFile<T extends BscFile = BscFile>(file: T) {
411
        delete this.files[file.srcPath.toLowerCase()];
153✔
412
        delete this.pkgMap[file.pkgPath.toLowerCase()];
153✔
413
        return file;
153✔
414
    }
415

416
    /**
417
     * Load a file into the program. If that file already exists, it is replaced.
418
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
419
     * @param srcPath the file path relative to the root dir
420
     * @param fileContents the file contents
421
     * @deprecated use `setFile` instead
422
     */
423
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
424
    /**
425
     * Load a file into the program. If that file already exists, it is replaced.
426
     * @param fileEntry an object that specifies src and dest for the file.
427
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
428
     * @deprecated use `setFile` instead
429
     */
430
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
431
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
432
        return this.setFile<T>(fileParam as any, fileContents);
1✔
433
    }
434

435
    /**
436
     * Load a file into the program. If that file already exists, it is replaced.
437
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
438
     * @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:/`)
439
     * @param fileContents the file contents
440
     */
441
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
442
    /**
443
     * Load a file into the program. If that file already exists, it is replaced.
444
     * @param fileEntry an object that specifies src and dest for the file.
445
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
446
     */
447
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
448
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
449
        //normalize the file paths
450
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,590✔
451

452
        //namespace contributions for the new/replaced file may differ; force the
453
        //program-level index to rebuild on next query
454
        this.invalidateNamespaceIndex();
1,590✔
455

456
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,590✔
457
            //if the file is already loaded, remove it
458
            if (this.hasFile(srcPath)) {
1,590✔
459
                this.removeFile(srcPath);
138✔
460
            }
461
            let fileExtension = path.extname(srcPath).toLowerCase();
1,590✔
462
            let file: BscFile | undefined;
463

464
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,590✔
465
                //add the file to the program
466
                const brsFile = this.assignFile(
1,325✔
467
                    new BrsFile(srcPath, pkgPath, this)
468
                );
469

470
                //add file to the `source` dependency list
471
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,325✔
472
                    this.createSourceScope();
1,137✔
473
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,137✔
474
                }
475

476
                let sourceObj: SourceObj = {
1,325✔
477
                    //TODO remove `pathAbsolute` in v1
478
                    pathAbsolute: srcPath,
479
                    srcPath: srcPath,
480
                    source: fileContents
481
                };
482
                this.plugins.emit('beforeFileParse', sourceObj);
1,325✔
483

484
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,325✔
485
                    brsFile.parse(sourceObj.source);
1,325✔
486
                });
487

488
                //notify plugins that this file has finished parsing
489
                this.plugins.emit('afterFileParse', brsFile);
1,325✔
490

491
                file = brsFile;
1,325✔
492

493
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,325✔
494

495
            } else if (
265✔
496
                //is xml file
497
                fileExtension === '.xml' &&
529✔
498
                //resides in the components folder (Roku will only parse xml files in the components folder)
499
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
500
            ) {
501
                //add the file to the program
502
                const xmlFile = this.assignFile(
261✔
503
                    new XmlFile(srcPath, pkgPath, this)
504
                );
505

506
                let sourceObj: SourceObj = {
261✔
507
                    //TODO remove `pathAbsolute` in v1
508
                    pathAbsolute: srcPath,
509
                    srcPath: srcPath,
510
                    source: fileContents
511
                };
512
                this.plugins.emit('beforeFileParse', sourceObj);
261✔
513

514
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
261✔
515
                    xmlFile.parse(sourceObj.source);
261✔
516
                });
517

518
                //notify plugins that this file has finished parsing
519
                this.plugins.emit('afterFileParse', xmlFile);
261✔
520

521
                file = xmlFile;
261✔
522

523
                //create a new scope for this xml file
524
                let scope = new XmlScope(xmlFile, this);
261✔
525
                this.addScope(scope);
261✔
526

527
                //register this compoent now that we have parsed it and know its component name
528
                this.registerComponent(xmlFile, scope);
261✔
529

530
                //notify plugins that the scope is created and the component is registered
531
                this.plugins.emit('afterScopeCreate', scope);
261✔
532
            } else {
533
                //TODO do we actually need to implement this? Figure out how to handle img paths
534
                // let genericFile = this.files[srcPath] = <any>{
535
                //     srcPath: srcPath,
536
                //     pkgPath: pkgPath,
537
                //     wasProcessed: true
538
                // } as File;
539
                // file = <any>genericFile;
540
            }
541
            return file;
1,590✔
542
        });
543
        return file as T;
1,590✔
544
    }
545

546
    /**
547
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
548
     * @param fileParam an object representing file paths
549
     * @param rootDir must be a pre-normalized path
550
     */
551
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
552
        let srcPath: string | undefined;
553
        let pkgPath: string | undefined;
554

555
        assert.ok(fileParam, 'fileParam is required');
1,597✔
556

557
        //lift the srcPath and pkgPath vars from the incoming param
558
        if (typeof fileParam === 'string') {
1,597✔
559
            fileParam = this.removePkgPrefix(fileParam);
1,128✔
560
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,128✔
561
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,128✔
562
        } else {
563
            let param: any = fileParam;
469✔
564

565
            if (param.src) {
469✔
566
                srcPath = s`${param.src}`;
466✔
567
            }
568
            if (param.srcPath) {
469✔
569
                srcPath = s`${param.srcPath}`;
2✔
570
            }
571
            if (param.dest) {
469✔
572
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
466✔
573
            }
574
            if (param.pkgPath) {
469✔
575
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
576
            }
577
        }
578

579
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
580
        if (!srcPath) {
1,597✔
581
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
582
        }
583
        //coerce srcPath to an absolute path
584
        if (!path.isAbsolute(srcPath)) {
1,597✔
585
            srcPath = util.standardizePath(srcPath);
1✔
586
        }
587

588
        //if there's no pkgPath, compute relative path from rootDir
589
        if (!pkgPath) {
1,597✔
590
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
591
        }
592

593
        assert.ok(srcPath, 'fileEntry.src is required');
1,597✔
594
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,597✔
595

596
        return {
1,597✔
597
            srcPath: srcPath,
598
            //remove leading slash from pkgPath
599
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
600
        };
601
    }
602

603
    /**
604
     * Remove any leading `pkg:/` found in the path
605
     */
606
    private removePkgPrefix(path: string) {
607
        return path.replace(/^pkg:\//i, '');
1,596✔
608
    }
609

610
    /**
611
     * Ensure source scope is created.
612
     * Note: automatically called internally, and no-op if it exists already.
613
     */
614
    public createSourceScope() {
615
        if (!this.scopes.source) {
1,559✔
616
            const sourceScope = new Scope('source', this, 'scope:source');
1,156✔
617
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,156✔
618
            this.addScope(sourceScope);
1,156✔
619
            this.plugins.emit('afterScopeCreate', sourceScope);
1,156✔
620
        }
621
    }
622

623
    /**
624
     * Find the file by its absolute path. This is case INSENSITIVE, since
625
     * Roku is a case insensitive file system. It is an error to have multiple files
626
     * with the same path with only case being different.
627
     * @param srcPath the absolute path to the file
628
     * @deprecated use `getFile` instead, which auto-detects the path type
629
     */
630
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
UNCOV
631
        srcPath = s`${srcPath}`;
×
UNCOV
632
        for (let filePath in this.files) {
×
UNCOV
633
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
UNCOV
634
                return this.files[filePath] as T;
×
635
            }
636
        }
637
    }
638

639
    /**
640
     * Get a list of files for the given (platform-normalized) pkgPath array.
641
     * Missing files are just ignored.
642
     * @deprecated use `getFiles` instead, which auto-detects the path types
643
     */
644
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
UNCOV
645
        return pkgPaths
×
UNCOV
646
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
UNCOV
647
            .filter(file => file !== undefined) as T;
×
648
    }
649

650
    /**
651
     * Get a file with the specified (platform-normalized) pkg path.
652
     * If not found, return undefined
653
     * @deprecated use `getFile` instead, which auto-detects the path type
654
     */
655
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
656
        return this.pkgMap[pkgPath.toLowerCase()] as T;
473✔
657
    }
658

659
    /**
660
     * Remove a set of files from the program
661
     * @param srcPaths can be an array of srcPath or destPath strings
662
     * @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
663
     */
664
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
665
        for (let srcPath of srcPaths) {
1✔
666
            this.removeFile(srcPath, normalizePath);
1✔
667
        }
668
    }
669

670
    /**
671
     * Remove a file from the program
672
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
673
     * @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
674
     */
675
    public removeFile(filePath: string, normalizePath = true) {
149✔
676
        this.logger.debug('Program.removeFile()', filePath);
153✔
677

678
        //namespace contributions may have included this file; force the program-level
679
        //index to rebuild on next query
680
        this.invalidateNamespaceIndex();
153✔
681

682
        let file = this.getFile(filePath, normalizePath);
153✔
683
        if (file) {
153!
684
            this.plugins.emit('beforeFileDispose', file);
153✔
685

686
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
687
            let scope = this.scopes[file.pkgPath];
153✔
688
            if (scope) {
153✔
689
                this.plugins.emit('beforeScopeDispose', scope);
11✔
690
                scope.dispose();
11✔
691
                //notify dependencies of this scope that it has been removed
692
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
693
                delete this.scopes[file.pkgPath];
11✔
694
                this.plugins.emit('afterScopeDispose', scope);
11✔
695
            }
696
            //remove the file from the program
697
            this.unassignFile(file);
153✔
698

699
            this.dependencyGraph.remove(file.dependencyGraphKey);
153✔
700

701
            //if this is a pkg:/source file, notify the `source` scope that it has changed
702
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
153✔
703
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
102✔
704
            }
705

706
            //if this is a component, remove it from our components map
707
            if (isXmlFile(file)) {
153✔
708
                this.unregisterComponent(file);
11✔
709
            }
710
            //dispose file
711
            file?.dispose();
153!
712
            this.plugins.emit('afterFileDispose', file);
153✔
713
        }
714
    }
715

716
    /**
717
     * Counter used to track which validation run is being logged
718
     */
719
    private validationRunSequence = 1;
1,540✔
720

721
    /**
722
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
723
     */
724
    private validationMinSyncDuration = 75;
1,540✔
725

726
    private validatePromise: Promise<void> | undefined;
727

728
    /**
729
     * Traverse the entire project, and validate all scopes
730
     */
731
    public validate(): void;
732
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
733
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
734
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
735
        const validationRunId = this.validationRunSequence++;
982✔
736
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
982!
737

738
        let previousValidationPromise = this.validatePromise;
982✔
739
        const deferred = new Deferred();
982✔
740

741
        if (options?.async) {
982✔
742
            //we're async, so create a new promise chain to resolve after this validation is done
743
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
744
                return deferred.promise;
159✔
745
            });
746

747
            //we are not async but there's a pending promise, then we cannot run this validation
748
        } else if (previousValidationPromise !== undefined) {
823!
UNCOV
749
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
750
        }
751

752
        if (options?.async) {
982✔
753
            //we're async, so create a new promise chain to resolve after this validation is done
754
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
755
                return deferred.promise;
159✔
756
            });
757

758
            //we are not async but there's a pending promise, then we cannot run this validation
759
        } else if (previousValidationPromise !== undefined) {
823!
UNCOV
760
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
761
        }
762

763
        const sequencer = new Sequencer({
982✔
764
            name: 'program.validate',
765
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,892✔
766
            minSyncDuration: this.validationMinSyncDuration
767
        });
768

769
        let beforeProgramValidateWasEmitted = false;
982✔
770

771
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
772
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
773
        sequencer
982✔
774
            .once(() => {
775
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
776
                if (options?.async) {
982✔
777
                    return previousValidationPromise;
159✔
778
                }
779
            })
780
            .once(() => {
781
                this.diagnostics = [];
980✔
782
                this.plugins.emit('beforeProgramValidate', this);
980✔
783
                beforeProgramValidateWasEmitted = true;
980✔
784
            })
785
            .forEach(() => Object.values(this.files), (file) => {
980✔
786
                if (!file.isValidated) {
1,250✔
787
                    this.plugins.emit('beforeFileValidate', {
1,187✔
788
                        program: this,
789
                        file: file
790
                    });
791

792
                    //emit an event to allow plugins to contribute to the file validation process
793
                    this.plugins.emit('onFileValidate', {
1,187✔
794
                        program: this,
795
                        file: file
796
                    });
797
                    //call file.validate() IF the file has that function defined
798
                    file.validate?.();
1,187!
799
                    file.isValidated = true;
1,186✔
800

801
                    this.plugins.emit('afterFileValidate', file);
1,186✔
802
                }
803
            })
804
            .forEach(Object.values(this.scopes), (scope) => {
805
                scope.linkSymbolTable();
2,033✔
806
                scope.validate();
2,033✔
807
                scope.unlinkSymbolTable();
2,031✔
808
            })
809
            .once(() => {
810
                this.detectDuplicateComponentNames();
972✔
811
            })
812
            .onCancel(() => {
813
                timeEnd('cancelled');
10✔
814
            })
815
            .onSuccess(() => {
816
                timeEnd();
972✔
817
            })
818
            .onComplete(() => {
819
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
820
                if (beforeProgramValidateWasEmitted) {
982✔
821
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
980✔
822
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
980✔
823
                }
824

825
                //regardless of the success of the validation, mark this run as complete
826
                deferred.resolve();
982✔
827
                //clear the validatePromise which means we're no longer running a validation
828
                this.validatePromise = undefined;
982✔
829
            });
830

831
        //run the sequencer in async mode if enabled
832
        if (options?.async) {
982✔
833
            return sequencer.run();
159✔
834

835
            //run the sequencer in sync mode
836
        } else {
837
            return sequencer.runSync();
823✔
838
        }
839
    }
840

841
    /**
842
     * Flag all duplicate component names
843
     */
844
    private detectDuplicateComponentNames() {
845
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
972✔
846
            const file = this.files[filePath];
1,241✔
847
            //if this is an XmlFile, and it has a valid `componentName` property
848
            if (isXmlFile(file) && file.componentName?.text) {
1,241✔
849
                let lowerName = file.componentName.text.toLowerCase();
233✔
850
                if (!map[lowerName]) {
233✔
851
                    map[lowerName] = [];
230✔
852
                }
853
                map[lowerName].push(file);
233✔
854
            }
855
            return map;
1,241✔
856
        }, {});
857

858
        for (let name in componentsByName) {
972✔
859
            const xmlFiles = componentsByName[name];
230✔
860
            //add diagnostics for every duplicate component with this name
861
            if (xmlFiles.length > 1) {
230✔
862
                for (let xmlFile of xmlFiles) {
3✔
863
                    const { componentName } = xmlFile;
6✔
864
                    this.diagnostics.push({
6✔
865
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
866
                        range: xmlFile.componentName.range,
867
                        file: xmlFile,
868
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
869
                            return {
6✔
870
                                location: util.createLocation(
871
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
872
                                    x.componentName.range
873
                                ),
874
                                message: 'Also defined here'
875
                            };
876
                        })
877
                    });
878
                }
879
            }
880
        }
881
    }
882

883
    /**
884
     * Get the files for a list of filePaths
885
     * @param filePaths can be an array of srcPath or a destPath strings
886
     * @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
887
     */
888
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
28✔
889
        return filePaths
28✔
890
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
891
            .filter(file => file !== undefined) as T[];
30✔
892
    }
893

894
    /**
895
     * Get the file at the given path
896
     * @param filePath can be a srcPath or a destPath
897
     * @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
898
     */
899
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,570✔
900
        if (typeof filePath !== 'string') {
8,377✔
901
            return undefined;
1,757✔
902
        } else if (path.isAbsolute(filePath)) {
6,620✔
903
            return this.files[
3,444✔
904
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,444✔
905
            ] as T;
906
        } else {
907
            return this.pkgMap[
3,176✔
908
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,176!
909
            ] as T;
910
        }
911
    }
912

913
    /**
914
     * Get a list of all scopes the file is loaded into
915
     * @param file the file
916
     */
917
    public getScopesForFile(file: XmlFile | BrsFile | string) {
918

919
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
592✔
920

921
        let result = [] as Scope[];
592✔
922
        if (resolvedFile) {
592✔
923
            for (let key in this.scopes) {
591✔
924
                let scope = this.scopes[key];
1,232✔
925

926
                if (scope.hasFile(resolvedFile)) {
1,232✔
927
                    result.push(scope);
602✔
928
                }
929
            }
930
        }
931
        return result;
592✔
932
    }
933

934
    /**
935
     * Get the first found scope for a file.
936
     */
937
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
938
        for (let key in this.scopes) {
2,826✔
939
            let scope = this.scopes[key];
7,005✔
940

941
            if (scope.hasFile(file)) {
7,005✔
942
                return scope;
2,685✔
943
            }
944
        }
945
    }
946

947
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
948
        let results = new Map<Statement, FileLink<Statement>>();
39✔
949
        const filesSearched = new Set<BrsFile>();
39✔
950
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
951
        let lowerName = name?.toLowerCase();
39!
952
        //look through all files in scope for matches
953
        for (const scope of this.getScopesForFile(originFile)) {
39✔
954
            for (const file of scope.getAllFiles()) {
39✔
955
                if (isXmlFile(file) || filesSearched.has(file)) {
45✔
956
                    continue;
3✔
957
                }
958
                filesSearched.add(file);
42✔
959

960
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
42✔
961
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
962
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
963
                        if (!results.has(statement)) {
36!
964
                            results.set(statement, { item: statement, file: file });
36✔
965
                        }
966
                    }
967
                }
968
            }
969
        }
970
        return [...results.values()];
39✔
971
    }
972

973
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
974
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
975
        const filesSearched = new Set<BrsFile>();
8✔
976

977
        //get all function names for the xml file and parents
978
        let funcNames = new Set<string>();
8✔
979
        let currentScope = scope;
8✔
980
        while (isXmlScope(currentScope)) {
8✔
981
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
982
                if (!filterName || name === filterName) {
14!
983
                    funcNames.add(name);
14✔
984
                }
985
            }
986
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
987
        }
988

989
        //look through all files in scope for matches
990
        for (const file of scope.getOwnFiles()) {
8✔
991
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
992
                continue;
8✔
993
            }
994
            filesSearched.add(file);
8✔
995

996
            for (const statement of file.parser.references.functionStatements) {
8✔
997
                if (funcNames.has(statement.name.text)) {
13!
998
                    if (!results.has(statement)) {
13!
999
                        results.set(statement, { item: statement, file: file });
13✔
1000
                    }
1001
                }
1002
            }
1003
        }
1004
        return [...results.values()];
8✔
1005
    }
1006

1007
    /**
1008
     * Find all available completion items at the given position
1009
     * @param filePath can be a srcPath or a destPath
1010
     * @param position the position (line & column) where completions should be found
1011
     */
1012
    public getCompletions(filePath: string, position: Position) {
1013
        let file = this.getFile(filePath);
77✔
1014
        if (!file) {
77!
UNCOV
1015
            return [];
×
1016
        }
1017

1018
        //find the scopes for this file
1019
        let scopes = this.getScopesForFile(file);
77✔
1020

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

1024
        const event: ProvideCompletionsEvent = {
77✔
1025
            program: this,
1026
            file: file,
1027
            scopes: scopes,
1028
            position: position,
1029
            completions: []
1030
        };
1031

1032
        this.plugins.emit('beforeProvideCompletions', event);
77✔
1033

1034
        this.plugins.emit('provideCompletions', event);
77✔
1035

1036
        this.plugins.emit('afterProvideCompletions', event);
77✔
1037

1038
        return event.completions;
77✔
1039
    }
1040

1041
    /**
1042
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1043
     */
1044
    public getWorkspaceSymbols() {
1045
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1046
            program: this,
1047
            workspaceSymbols: []
1048
        };
1049
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1050
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1051
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1052
        return event.workspaceSymbols;
22✔
1053
    }
1054

1055
    /**
1056
     * Given a position in a file, if the position is sitting on some type of identifier,
1057
     * go to the definition of that identifier (where this thing was first defined)
1058
     */
1059
    public getDefinition(srcPath: string, position: Position): Location[] {
1060
        let file = this.getFile(srcPath);
19✔
1061
        if (!file) {
19!
UNCOV
1062
            return [];
×
1063
        }
1064

1065
        const event: ProvideDefinitionEvent = {
19✔
1066
            program: this,
1067
            file: file,
1068
            position: position,
1069
            definitions: []
1070
        };
1071

1072
        this.plugins.emit('beforeProvideDefinition', event);
19✔
1073
        this.plugins.emit('provideDefinition', event);
19✔
1074
        this.plugins.emit('afterProvideDefinition', event);
19✔
1075
        return event.definitions;
19✔
1076
    }
1077

1078
    /**
1079
     * Get hover information for a file and position
1080
     */
1081
    public getHover(srcPath: string, position: Position): Hover[] {
1082
        let file = this.getFile(srcPath);
30✔
1083
        let result: Hover[];
1084
        if (file) {
30!
1085
            const event = {
30✔
1086
                program: this,
1087
                file: file,
1088
                position: position,
1089
                scopes: this.getScopesForFile(file),
1090
                hovers: []
1091
            } as ProvideHoverEvent;
1092
            this.plugins.emit('beforeProvideHover', event);
30✔
1093
            this.plugins.emit('provideHover', event);
30✔
1094
            this.plugins.emit('afterProvideHover', event);
30✔
1095
            result = event.hovers;
30✔
1096
        }
1097

1098
        return result ?? [];
30!
1099
    }
1100

1101
    /**
1102
     * Get full list of document symbols for a file
1103
     * @param srcPath path to the file
1104
     */
1105
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1106
        let file = this.getFile(srcPath);
24✔
1107
        if (file) {
24!
1108
            const event: ProvideDocumentSymbolsEvent = {
24✔
1109
                program: this,
1110
                file: file,
1111
                documentSymbols: []
1112
            };
1113
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1114
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1115
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1116
            return event.documentSymbols;
24✔
1117
        } else {
UNCOV
1118
            return undefined;
×
1119
        }
1120
    }
1121

1122
    /**
1123
     * Get the selection ranges for the given positions in a file. Used for expand/shrink selection.
1124
     * @param srcPath path to the file
1125
     * @param positions the positions to get selection ranges for
1126
     */
1127
    public getSelectionRanges(srcPath: string, positions: Position[]): SelectionRange[] {
1128
        const file = this.getFile(srcPath);
15✔
1129
        if (file) {
15✔
1130
            const event: ProvideSelectionRangesEvent = {
14✔
1131
                program: this,
1132
                file: file,
1133
                positions: positions,
1134
                selectionRanges: []
1135
            };
1136
            this.plugins.emit('beforeProvideSelectionRanges', event);
14✔
1137
            this.plugins.emit('provideSelectionRanges', event);
14✔
1138
            this.plugins.emit('afterProvideSelectionRanges', event);
14✔
1139
            return event.selectionRanges;
14✔
1140
        }
1141
        return [];
1✔
1142
    }
1143

1144
    /**
1145
     * Compute code actions for the given file and range
1146
     */
1147
    public getCodeActions(srcPath: string, range: Range) {
1148
        const codeActions = [] as CodeAction[];
52✔
1149
        const file = this.getFile(srcPath);
52✔
1150
        if (file) {
52✔
1151
            const diagnostics = this
51✔
1152
                //get all current diagnostics (filtered by diagnostic filters)
1153
                .getDiagnostics()
1154
                //only keep diagnostics related to this file
1155
                .filter(x => x.file === file)
89✔
1156
                //only keep diagnostics that touch this range
1157
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
66✔
1158

1159
            const scopes = this.getScopesForFile(file);
51✔
1160

1161
            this.plugins.emit('onGetCodeActions', {
51✔
1162
                program: this,
1163
                file: file,
1164
                range: range,
1165
                diagnostics: diagnostics,
1166
                scopes: scopes,
1167
                codeActions: codeActions
1168
            });
1169
        }
1170
        return codeActions;
52✔
1171
    }
1172

1173
    /**
1174
     * Get semantic tokens for the specified file
1175
     */
1176
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1177
        const file = this.getFile(srcPath);
16✔
1178
        if (file) {
16!
1179
            const result = [] as SemanticToken[];
16✔
1180
            this.plugins.emit('onGetSemanticTokens', {
16✔
1181
                program: this,
1182
                file: file,
1183
                scopes: this.getScopesForFile(file),
1184
                semanticTokens: result
1185
            });
1186
            return result;
16✔
1187
        }
1188
    }
1189

1190
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1191
        let file: BrsFile = this.getFile(filePath);
188✔
1192
        if (!file || !isBrsFile(file)) {
188✔
1193
            return [];
3✔
1194
        }
1195
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1196
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1197
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1198
    }
1199

1200
    public getReferences(srcPath: string, position: Position): Location[] {
1201
        //find the file
1202
        let file = this.getFile(srcPath);
4✔
1203
        if (!file) {
4!
UNCOV
1204
            return null;
×
1205
        }
1206

1207
        const event: ProvideReferencesEvent = {
4✔
1208
            program: this,
1209
            file: file,
1210
            position: position,
1211
            references: []
1212
        };
1213

1214
        this.plugins.emit('beforeProvideReferences', event);
4✔
1215
        this.plugins.emit('provideReferences', event);
4✔
1216
        this.plugins.emit('afterProvideReferences', event);
4✔
1217

1218
        return event.references;
4✔
1219
    }
1220

1221
    /**
1222
     * Get a list of all script imports, relative to the specified pkgPath
1223
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1224
     */
1225
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1226
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1227

1228
        let result = [] as CompletionItem[];
3✔
1229
        /**
1230
         * hashtable to prevent duplicate results
1231
         */
1232
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1233

1234
        //restrict to only .brs files
1235
        for (let key in this.files) {
3✔
1236
            let file = this.files[key];
4✔
1237
            if (
4✔
1238
                //is a BrightScript or BrighterScript file
1239
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1240
                //this file is not the current file
1241
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1242
            ) {
1243
                //add the relative path
1244
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1245
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1246
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1247
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1248
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1249
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1250

1251
                    result.push({
3✔
1252
                        label: relativePath,
1253
                        detail: file.srcPath,
1254
                        kind: CompletionItemKind.File,
1255
                        textEdit: {
1256
                            newText: relativePath,
1257
                            range: scriptImport.filePathRange
1258
                        }
1259
                    });
1260

1261
                    //add the absolute path
1262
                    result.push({
3✔
1263
                        label: filePkgPath,
1264
                        detail: file.srcPath,
1265
                        kind: CompletionItemKind.File,
1266
                        textEdit: {
1267
                            newText: filePkgPath,
1268
                            range: scriptImport.filePathRange
1269
                        }
1270
                    });
1271
                }
1272
            }
1273
        }
1274
        return result;
3✔
1275
    }
1276

1277
    /**
1278
     * Transpile a single file and get the result as a string.
1279
     * This does not write anything to the file system.
1280
     *
1281
     * This should only be called by `LanguageServer`.
1282
     * Internal usage should call `_getTranspiledFileContents` instead.
1283
     * @param filePath can be a srcPath or a destPath
1284
     */
1285
    public async getTranspiledFileContents(filePath: string) {
1286
        const file = this.getFile(filePath);
4✔
1287
        const fileMap: FileObj[] = [{
4✔
1288
            src: file.srcPath,
1289
            dest: file.pkgPath
1290
        }];
1291
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1292
        const result = this._getTranspiledFileContents(
4✔
1293
            file
1294
        );
1295
        await this._chainInputSourceMap(result, file);
4✔
1296
        this.afterProgramTranspile(entries, astEditor);
4✔
1297
        return result;
4✔
1298
    }
1299

1300
    /**
1301
     * Internal function used to transpile files.
1302
     * This does not write anything to the file system
1303
     */
1304
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1305
        const editor = new AstEditor();
343✔
1306
        this.plugins.emit('beforeFileTranspile', {
343✔
1307
            program: this,
1308
            file: file,
1309
            outputPath: outputPath,
1310
            editor: editor
1311
        });
1312

1313
        //if we have any edits, assume the file needs to be transpiled
1314
        if (editor.hasChanges) {
343✔
1315
            //use the `editor` because it'll track the previous value for us and revert later on
1316
            editor.setProperty(file, 'needsTranspiled', true);
82✔
1317
        }
1318

1319
        //transpile the file
1320
        const result = file.transpile();
343✔
1321

1322
        //generate the typedef if enabled
1323
        let typedef: string;
1324
        if (isBrsFile(file) && this.options.emitDefinitions) {
343✔
1325
            typedef = file.getTypedef();
2✔
1326
        }
1327

1328
        const event: AfterFileTranspileEvent = {
343✔
1329
            program: this,
1330
            file: file,
1331
            outputPath: outputPath,
1332
            editor: editor,
1333
            code: result.code,
1334
            map: result.map,
1335
            typedef: typedef
1336
        };
1337
        this.plugins.emit('afterFileTranspile', event);
343✔
1338

1339
        //undo all `editor` edits that may have been applied to this file.
1340
        editor.undoAll();
343✔
1341

1342
        return {
343✔
1343
            srcPath: file.srcPath,
1344
            pkgPath: file.pkgPath,
1345
            code: event.code,
1346
            map: event.map,
1347
            typedef: event.typedef
1348
        };
1349
    }
1350

1351
    /**
1352
     * If the file has an incoming sourcemap (from a prebuild step), chain it into the
1353
     * generated sourcemap so the output map traces all the way back to the original source.
1354
     * This is async because SourceMapConsumer requires async initialisation in source-map v0.7.
1355
     */
1356
    private async _chainInputSourceMap(result: FileTranspileResult, file: BscFile): Promise<void> {
1357
        if (result.map) {
47✔
1358
            const inputMap = await util.resolveInputSourceMap(file.fileContents ?? '', file.srcPath);
15!
1359
            if (inputMap) {
15✔
1360
                await util.applySourceMap(result.map, inputMap, file.srcPath);
8✔
1361
            }
1362
        }
1363
    }
1364

1365
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1366
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1367
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
47✔
1368
            collection[s`${entry.src}`] = entry;
30✔
1369
            return collection;
30✔
1370
        }, {});
1371

1372
        const getOutputPath = (file: BscFile) => {
47✔
1373
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
97✔
1374
            if (!filePathObj) {
97✔
1375
                //this file has been added in-memory, from a plugin, for example
1376
                filePathObj = {
47✔
1377
                    //add an interpolated src path (since it doesn't actually exist in memory)
1378
                    src: `bsc:/${file.pkgPath}`,
1379
                    dest: file.pkgPath
1380
                };
1381
            }
1382
            //replace the file extension
1383
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
97✔
1384
            //prepend the staging folder path
1385
            outputPath = s`${stagingDir}/${outputPath}`;
97✔
1386
            return outputPath;
97✔
1387
        };
1388

1389
        const entries = Object.values(this.files)
47✔
1390
            //only include the files from fileEntries
1391
            .filter(file => !!mappedFileEntries[file.srcPath])
49✔
1392
            .map(file => {
1393
                return {
28✔
1394
                    file: file,
1395
                    outputPath: getOutputPath(file)
1396
                };
1397
            })
1398
            //sort the entries to make transpiling more deterministic
1399
            .sort((a, b) => {
1400
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1401
            });
1402

1403
        const astEditor = new AstEditor();
47✔
1404

1405
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
47✔
1406
        return {
47✔
1407
            entries: entries,
1408
            getOutputPath: getOutputPath,
1409
            astEditor: astEditor
1410
        };
1411
    }
1412

1413
    public async transpile(fileEntries: FileObj[], stagingDir: string) {
1414
        const { entries, getOutputPath, astEditor } = this.beforeProgramTranspile(fileEntries, stagingDir);
42✔
1415

1416
        const processedFiles = new Set<string>();
42✔
1417

1418
        const transpileFile = async (srcPath: string, outputPath?: string) => {
42✔
1419
            //find the file in the program
1420
            const file = this.getFile(srcPath);
45✔
1421
            //mark this file as processed so we don't process it more than once
1422
            processedFiles.add(outputPath?.toLowerCase());
45!
1423

1424
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
45✔
1425
                //skip transpiling typedef files
1426
                if (isBrsFile(file) && file.isTypedef) {
44✔
1427
                    return;
1✔
1428
                }
1429

1430
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
43✔
1431
                await this._chainInputSourceMap(fileTranspileResult, file);
43✔
1432

1433
                //make sure the full dir path exists
1434
                await fsExtra.ensureDir(path.dirname(outputPath));
43✔
1435

1436
                if (await fsExtra.pathExists(outputPath)) {
43!
UNCOV
1437
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1438
                }
1439
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
43✔
1440
                await Promise.all([
43✔
1441
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1442
                    writeMapPromise
1443
                ]);
1444

1445
                if (fileTranspileResult.typedef) {
43✔
1446
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1447
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1448
                }
1449
            }
1450
        };
1451

1452
        let promises = entries.map(async (entry) => {
42✔
1453
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
22!
1454
        });
1455

1456
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1457
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
42✔
1458
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
41✔
1459
        }
1460
        await Promise.all(promises);
42✔
1461

1462
        //transpile any new files that plugins added since the start of this transpile process
1463
        do {
42✔
1464
            promises = [];
62✔
1465
            for (const key in this.files) {
62✔
1466
                const file = this.files[key];
69✔
1467
                //this is a new file
1468
                const outputPath = getOutputPath(file);
69✔
1469
                if (!processedFiles.has(outputPath?.toLowerCase())) {
69!
1470
                    promises.push(
23✔
1471
                        transpileFile(file?.srcPath, outputPath)
69!
1472
                    );
1473
                }
1474
            }
1475
            if (promises.length > 0) {
62✔
1476
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1477
                await Promise.all(promises);
20✔
1478
            }
1479
        }
1480
        while (promises.length > 0);
1481
        this.afterProgramTranspile(entries, astEditor);
42✔
1482
    }
1483

1484
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1485
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
46✔
1486
        astEditor.undoAll();
46✔
1487
    }
1488

1489
    /**
1490
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1491
     */
1492
    public findFilesForFunction(functionName: string) {
1493
        const files = [] as BscFile[];
33✔
1494
        const lowerFunctionName = functionName.toLowerCase();
33✔
1495
        //find every file with this function defined
1496
        for (const file of Object.values(this.files)) {
33✔
1497
            if (isBrsFile(file)) {
123✔
1498
                //TODO handle namespace-relative function calls
1499
                //if the file has a function with this name
1500
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
88✔
1501
                    files.push(file);
24✔
1502
                }
1503
            }
1504
        }
1505
        return files;
33✔
1506
    }
1507

1508
    /**
1509
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1510
     */
1511
    public findFilesForClass(className: string) {
1512
        const files = [] as BscFile[];
33✔
1513
        const lowerClassName = className.toLowerCase();
33✔
1514
        //find every file with this class defined
1515
        for (const file of Object.values(this.files)) {
33✔
1516
            if (isBrsFile(file)) {
123✔
1517
                //TODO handle namespace-relative classes
1518
                //if the file has a function with this name
1519
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
88✔
1520
                    files.push(file);
3✔
1521
                }
1522
            }
1523
        }
1524
        return files;
33✔
1525
    }
1526

1527
    public findFilesForNamespace(name: string) {
1528
        const files = [] as BscFile[];
33✔
1529
        const lowerName = name.toLowerCase();
33✔
1530
        //find every file with this class defined
1531
        for (const file of Object.values(this.files)) {
33✔
1532
            if (isBrsFile(file)) {
123✔
1533
                if (file.parser.references.namespaceStatements.find((x) => {
88✔
1534
                    const namespaceName = x.name.toLowerCase();
14✔
1535
                    return (
14✔
1536
                        //the namespace name matches exactly
1537
                        namespaceName === lowerName ||
18✔
1538
                        //the full namespace starts with the name (honoring the part boundary)
1539
                        namespaceName.startsWith(lowerName + '.')
1540
                    );
1541
                })) {
1542
                    files.push(file);
12✔
1543
                }
1544
            }
1545
        }
1546
        return files;
33✔
1547
    }
1548

1549
    public findFilesForEnum(name: string) {
1550
        const files = [] as BscFile[];
34✔
1551
        const lowerName = name.toLowerCase();
34✔
1552
        //find every file with this class defined
1553
        for (const file of Object.values(this.files)) {
34✔
1554
            if (isBrsFile(file)) {
124✔
1555
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
89✔
1556
                    files.push(file);
1✔
1557
                }
1558
            }
1559
        }
1560
        return files;
34✔
1561
    }
1562

1563
    private _manifest: Map<string, string>;
1564

1565
    /**
1566
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1567
     * @param parsedManifest The manifest map to read from and modify
1568
     */
1569
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1570
        // Lift the bs_consts defined in the manifest
1571
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1572

1573
        // Override or delete any bs_consts defined in the bs config
1574
        for (const key in this.options?.manifest?.bs_const) {
19!
1575
            const value = this.options.manifest.bs_const[key];
3✔
1576
            if (value === null) {
3✔
1577
                bsConsts.delete(key);
1✔
1578
            } else {
1579
                bsConsts.set(key, value);
2✔
1580
            }
1581
        }
1582

1583
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1584
        let constString = '';
19✔
1585
        for (const [key, value] of bsConsts) {
19✔
1586
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1587
        }
1588

1589
        // Set the updated bs_const value
1590
        parsedManifest.set('bs_const', constString);
19✔
1591
    }
1592

1593
    /**
1594
     * Try to find and load the manifest into memory
1595
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1596
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1597
     */
1598
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,064✔
1599
        //if we already have a manifest instance, and should not replace...then don't replace
1600
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,076!
UNCOV
1601
            return;
×
1602
        }
1603
        let manifestPath = manifestFileObj
1,076✔
1604
            ? manifestFileObj.src
1,076✔
1605
            : path.join(this.options.rootDir, 'manifest');
1606

1607
        try {
1,076✔
1608
            // we only load this manifest once, so do it sync to improve speed downstream
1609
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,076✔
1610
            const parsedManifest = parseManifest(contents);
19✔
1611
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1612
            this._manifest = parsedManifest;
19✔
1613
        } catch (e) {
1614
            this._manifest = new Map();
1,057✔
1615
        }
1616
    }
1617

1618
    /**
1619
     * Get a map of the manifest information
1620
     */
1621
    public getManifest() {
1622
        if (!this._manifest) {
1,409✔
1623
            this.loadManifest();
1,063✔
1624
        }
1625
        return this._manifest;
1,409✔
1626
    }
1627

1628
    public dispose() {
1629
        this.plugins.emit('beforeProgramDispose', { program: this });
1,372✔
1630

1631
        for (let filePath in this.files) {
1,372✔
1632
            this.files[filePath].dispose();
1,383✔
1633
        }
1634
        for (let name in this.scopes) {
1,372✔
1635
            this.scopes[name].dispose();
2,726✔
1636
        }
1637
        this.globalScope.dispose();
1,372✔
1638
        this.dependencyGraph.dispose();
1,372✔
1639
    }
1640
}
1641

1642
export interface FileTranspileResult {
1643
    srcPath: string;
1644
    pkgPath: string;
1645
    code: string;
1646
    map: SourceMapGenerator;
1647
    typedef: string;
1648
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc