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

rokucommunity / brighterscript / 28390508118

29 Jun 2026 05:26PM UTC coverage: 88.483% (+0.04%) from 88.445%
28390508118

push

github

web-flow
Validate eval/rsg_version against firmware lifecycle (#1698)

8967 of 10668 branches covered (84.06%)

Branch coverage included in aggregate %.

99 of 105 new or added lines in 6 files covered. (94.29%)

11254 of 12185 relevant lines covered (92.36%)

2053.73 hits per line

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

92.97
/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 * as semver from 'semver';
1✔
5
import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange, InlayHint } from 'vscode-languageserver';
6
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
1✔
7
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
8
import { Scope } from './Scope';
1✔
9
import type { NamespaceContainer, NamespaceFileContribution } from './Scope';
10
import { SymbolTable } from './SymbolTable';
1✔
11
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
12
import { BrsFile } from './files/BrsFile';
1✔
13
import { XmlFile } from './files/XmlFile';
1✔
14
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent, ProvideInlayHintsEvent, OnGetSourceFixAllCodeActionsEvent } from './interfaces';
15
import type { SourceFixAllCodeAction } from './CodeActionUtil';
16
import { codeActionUtil } from './CodeActionUtil';
1✔
17
import { standardizePath as s, util } from './util';
1✔
18
import { XmlScope } from './XmlScope';
1✔
19
import { DiagnosticFilterer } from './DiagnosticFilterer';
1✔
20
import { DependencyGraph } from './DependencyGraph';
1✔
21
import type { Logger } from './logging';
22
import { LogLevel, createLogger } from './logging';
1✔
23
import chalk from 'chalk';
1✔
24
import { globalFile } from './globalCallables';
1✔
25
import { parseManifest, parseManifestEntries, getBsConst } from './preprocessor/Manifest';
1✔
26
import type { ManifestEntry } from './preprocessor/Manifest';
27
import { DEFAULT_MIN_FIRMWARE_VERSION, RSG_VERSIONS } from './RokuConstants';
1✔
28
import { URI } from 'vscode-uri';
1✔
29
import PluginInterface from './PluginInterface';
1✔
30
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
1✔
31
import type { FunctionStatement, NamespaceStatement } from './parser/Statement';
32
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
33
import { AstEditor } from './astUtils/AstEditor';
1✔
34
import type { SourceMapGenerator } from 'source-map';
35
import type { Statement } from './parser/AstNode';
36
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
37
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
38
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
1✔
39
import { Sequencer } from './common/Sequencer';
1✔
40
import { Deferred } from './deferred';
1✔
41

42
const startOfSourcePkgPath = `source${path.sep}`;
1✔
43
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
44
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
45

46
export interface SourceObj {
47
    /**
48
     * @deprecated use `srcPath` instead
49
     */
50
    pathAbsolute: string;
51
    srcPath: string;
52
    source: string;
53
    definitions?: string;
54
}
55

56
export interface TranspileObj {
57
    file: BscFile;
58
    outputPath: string;
59
}
60

61
export interface SignatureInfoObj {
62
    index: number;
63
    key: string;
64
    signature: SignatureInformation;
65
}
66

67
export class Program {
1✔
68
    constructor(
69
        /**
70
         * The root directory for this program
71
         */
72
        options: BsConfig,
73
        logger?: Logger,
74
        plugins?: PluginInterface
75
    ) {
76
        this.options = util.normalizeConfig(options);
1,793✔
77
        this.logger = logger ?? createLogger(options);
1,793✔
78
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,793✔
79

80
        //inject the bsc plugin as the first plugin in the stack.
81
        this.plugins.addFirst(new BscPlugin());
1,793✔
82

83
        //normalize the root dir path
84
        this.options.rootDir = util.getRootDir(this.options);
1,793✔
85

86
        this.createGlobalScope();
1,793✔
87
    }
88

89
    public options: FinalizedBsConfig;
90
    public logger: Logger;
91

92
    private createGlobalScope() {
93
        //create the 'global' scope
94
        this.globalScope = new Scope('global', this, 'scope:global');
1,793✔
95
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,793✔
96
        this.scopes.global = this.globalScope;
1,793✔
97
        //hardcode the files list for global scope to only contain the global file
98
        this.globalScope.getAllFiles = () => [globalFile];
28,867✔
99
        this.globalScope.validate();
1,793✔
100
        //for now, disable validation of global scope because the global files have some duplicate method declarations
101
        this.globalScope.getDiagnostics = () => [];
1,793✔
102
        //TODO we might need to fix this because the isValidated clears stuff now
103
        (this.globalScope as any).isValidated = true;
1,793✔
104
    }
105

106
    /**
107
     * A graph of all files and their dependencies.
108
     * For example:
109
     *      File.xml -> [lib1.brs, lib2.brs]
110
     *      lib2.brs -> [lib3.brs] //via an import statement
111
     */
112
    private dependencyGraph = new DependencyGraph();
1,793✔
113

114
    private diagnosticFilterer = new DiagnosticFilterer();
1,793✔
115

116
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,793✔
117

118
    /**
119
     * A scope that contains all built-in global functions.
120
     * All scopes should directly or indirectly inherit from this scope
121
     */
122
    public globalScope: Scope = undefined as any;
1,793✔
123

124
    /**
125
     * Plugins which can provide extra diagnostics or transform AST
126
     */
127
    public plugins: PluginInterface;
128

129
    /**
130
     * A set of diagnostics. This does not include any of the scope diagnostics.
131
     * Should only be set from `this.validate()`
132
     */
133
    private diagnostics = [] as BsDiagnostic[];
1,793✔
134

135
    /**
136
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
137
     */
138
    public get bslibPkgPath() {
139
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
140
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
487✔
141
            return bslibAliasedRokuModulesPkgPath;
2✔
142

143
            //if there's a non-aliased version of bslib from roku_modules, use that
144
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
485✔
145
            return bslibNonAliasedRokuModulesPkgPath;
3✔
146

147
            //default to the embedded version
148
        } else {
149
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
482✔
150
        }
151
    }
152

153
    public get bslibPrefix() {
154
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
456✔
155
            return 'rokucommunity_bslib';
3✔
156
        } else {
157
            return 'bslib';
453✔
158
        }
159
    }
160

161

162
    /**
163
     * A map of every file loaded into this program, indexed by its original file location
164
     */
165
    public files = {} as Record<string, BscFile>;
1,793✔
166
    private pkgMap = {} as Record<string, BscFile>;
1,793✔
167

168
    /**
169
     * Map from a lower-cased namespace name part to the set of `BrsFile`s that contribute
170
     * to it. Built lazily, invalidated whenever any file is added, removed, or re-parsed
171
     * (`setFile` and `removeFile` both clear it).
172
     *
173
     * Used by `ScopeNamespaceLookup` to resolve a namespace name to its contributing
174
     * files in O(1), then intersect against the scope's file set.
175
     */
176
    private namespaceContributors: Map<string, Set<BrsFile>> | undefined;
177

178
    /**
179
     * Look up the set of `BrsFile`s that declare any part of the given namespace name
180
     * (lowercased). Returns `undefined` when no file contributes.
181
     * @internal
182
     */
183
    protected getNamespaceContributors(namespaceNameLower: string): Set<BrsFile> | undefined {
184
        if (!this.namespaceContributors) {
333✔
185
            this.namespaceContributors = this.buildNamespaceContributors();
201✔
186
        }
187
        return this.namespaceContributors.get(namespaceNameLower);
333✔
188
    }
189

190
    private buildNamespaceContributors(): Map<string, Set<BrsFile>> {
191
        const contributors = new Map<string, Set<BrsFile>>();
201✔
192
        for (const file of Object.values(this.files)) {
201✔
193
            if (isBrsFile(file)) {
275✔
194
                // eslint-disable-next-line @typescript-eslint/dot-notation
195
                for (const nameLower of file['getNamespaceContributions']().keys()) {
257✔
196
                    let set = contributors.get(nameLower);
358✔
197
                    if (!set) {
358✔
198
                        set = new Set<BrsFile>();
330✔
199
                        contributors.set(nameLower, set);
330✔
200
                    }
201
                    set.add(file);
358✔
202
                }
203
            }
204
        }
205
        return contributors;
201✔
206
    }
207

208
    /**
209
     * Cached slow-path namespace aggregates, keyed by `(nameLower, sorted-contributor-pkgPaths)`.
210
     * Two scopes with the same in-scope file set for a multi-contributor namespace share
211
     * the same aggregate object (and therefore the same merged statement collections and
212
     * symbolTable instance). Built lazily, invalidated alongside `namespaceContributors`.
213
     *
214
     * The aggregate is stored as a `NamespaceContainer` whose `namespaces` field is an
215
     * empty Map: scopes always wrap the aggregate before returning to plugins, and the
216
     * wrapper supplies its own scope-filtered children. Plugins never see the aggregate
217
     * directly.
218
     */
219
    private aggregateNamespaceContainerCache: Map<string, NamespaceContainer> | undefined;
220

221
    /**
222
     * Get or build the shared aggregate for a namespace whose in-scope contributors
223
     * include more than one file. The aggregate's heavy fields are computed once per
224
     * unique `(nameLower, contributing-files-set)` and reused across every scope that
225
     * sees the same set.
226
     * @internal
227
     */
228
    protected getAggregateNamespaceContainer(nameLower: string, contributions: NamespaceFileContribution[]): NamespaceContainer {
229
        if (!this.aggregateNamespaceContainerCache) {
33✔
230
            this.aggregateNamespaceContainerCache = new Map<string, NamespaceContainer>();
22✔
231
        }
232
        //sorted pkgPaths ensure two scopes with the same contributor set hit the same key
233
        const key = nameLower + '|' + contributions
33✔
234
            .map(c => c.file.pkgPath.toLowerCase())
69✔
235
            .sort()
236
            .join('|');
237
        let aggregate = this.aggregateNamespaceContainerCache.get(key);
33✔
238
        if (!aggregate) {
33✔
239
            aggregate = this.buildAggregateNamespaceContainer(contributions);
26✔
240
            this.aggregateNamespaceContainerCache.set(key, aggregate);
26✔
241
        }
242
        return aggregate;
33✔
243
    }
244

245
    private buildAggregateNamespaceContainer(contributions: NamespaceFileContribution[]): NamespaceContainer {
246
        const first = contributions[0];
26✔
247
        //field order matches the NamespaceContainer interface declaration so aggregates
248
        //share a single V8 hidden class with the per-scope wrapper containers
249
        const aggregate: NamespaceContainer = {
26✔
250
            file: first.file,
251
            fullName: first.fullName,
252
            nameRange: first.nameRange,
253
            lastPartName: first.lastPartName,
254
            namespaces: new Map(),
255
            statements: undefined,
256
            classStatements: undefined,
257
            functionStatements: undefined,
258
            enumStatements: undefined,
259
            constStatements: undefined,
260
            symbolTable: undefined
261
        };
262
        for (const contribution of contributions) {
26✔
263
            if (contribution.statements?.length) {
55✔
264
                (aggregate.statements ??= []).push(...contribution.statements);
45✔
265
            }
266
            if (contribution.classStatements) {
55✔
267
                aggregate.classStatements = { ...(aggregate.classStatements ?? {}), ...contribution.classStatements };
7✔
268
            }
269
            if (contribution.functionStatements) {
55✔
270
                aggregate.functionStatements = { ...(aggregate.functionStatements ?? {}), ...contribution.functionStatements };
29✔
271
            }
272
            if (contribution.enumStatements) {
55✔
273
                aggregate.enumStatements ??= new Map();
5!
274
                for (const [key, value] of contribution.enumStatements) {
5✔
275
                    aggregate.enumStatements.set(key, value);
5✔
276
                }
277
            }
278
            if (contribution.constStatements) {
55✔
279
                aggregate.constStatements ??= new Map();
6✔
280
                for (const [key, value] of contribution.constStatements) {
6✔
281
                    aggregate.constStatements.set(key, value);
7✔
282
                }
283
            }
284
            if (contribution.symbolTable) {
55✔
285
                aggregate.symbolTable ??= new SymbolTable(`Namespace Multi-File Aggregate: '${first.fullName}'`);
45✔
286
                aggregate.symbolTable.mergeSymbolTable(contribution.symbolTable);
45✔
287
            }
288
        }
289
        return aggregate;
26✔
290
    }
291

292
    /**
293
     * Invalidate the program-level namespace contributors map and the slow-path aggregate
294
     * cache. Called by `setFile` and `removeFile`; downstream scope namespace lookups
295
     * already rebuild via the dependency-graph invalidation chain, so this only needs
296
     * to drop the cached maps.
297
     */
298
    private invalidateNamespaceContributorCache() {
299
        this.namespaceContributors = undefined;
1,936✔
300
        this.aggregateNamespaceContainerCache = undefined;
1,936✔
301
    }
302

303
    private scopes = {} as Record<string, Scope>;
1,793✔
304

305
    protected addScope(scope: Scope) {
306
        this.scopes[scope.name] = scope;
1,613✔
307
    }
308

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

317
    /**
318
     * Get the component with the specified name
319
     */
320
    public getComponent(componentName: string) {
321
        if (componentName) {
675✔
322
            //return the first compoment in the list with this name
323
            //(components are ordered in this list by pkgPath to ensure consistency)
324
            return this.components[componentName.toLowerCase()]?.[0];
625✔
325
        } else {
326
            return undefined;
50✔
327
        }
328
    }
329

330
    /**
331
     * Register (or replace) the reference to a component in the component map
332
     */
333
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
334
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
279✔
335
        if (!this.components[key]) {
279✔
336
            this.components[key] = [];
267✔
337
        }
338
        this.components[key].push({
279✔
339
            file: xmlFile,
340
            scope: scope
341
        });
342
        this.components[key].sort((a, b) => {
279✔
343
            const pathA = a.file.pkgPath.toLowerCase();
5✔
344
            const pathB = b.file.pkgPath.toLowerCase();
5✔
345
            if (pathA < pathB) {
5✔
346
                return -1;
1✔
347
            } else if (pathA > pathB) {
4!
348
                return 1;
4✔
349
            }
350
            return 0;
×
351
        });
352
        this.syncComponentDependencyGraph(this.components[key]);
279✔
353
    }
354

355
    /**
356
     * Remove the specified component from the components map
357
     */
358
    private unregisterComponent(xmlFile: XmlFile) {
359
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
11✔
360
        const arr = this.components[key] || [];
11!
361
        for (let i = 0; i < arr.length; i++) {
11✔
362
            if (arr[i].file === xmlFile) {
11!
363
                arr.splice(i, 1);
11✔
364
                break;
11✔
365
            }
366
        }
367
        this.syncComponentDependencyGraph(arr);
11✔
368
    }
369

370
    /**
371
     * re-attach the dependency graph with a new key for any component who changed
372
     * their position in their own named array (only matters when there are multiple
373
     * components with the same name)
374
     */
375
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
376
        //reattach every dependency graph
377
        for (let i = 0; i < components.length; i++) {
290✔
378
            const { file, scope } = components[i];
285✔
379

380
            //attach (or re-attach) the dependencyGraph for every component whose position changed
381
            if (file.dependencyGraphIndex !== i) {
285✔
382
                file.dependencyGraphIndex = i;
281✔
383
                file.attachDependencyGraph(this.dependencyGraph);
281✔
384
                scope.attachDependencyGraph(this.dependencyGraph);
281✔
385
            }
386
        }
387
    }
388

389
    /**
390
     * Get a list of all files that are included in the project but are not referenced
391
     * by any scope in the program.
392
     */
393
    public getUnreferencedFiles() {
394
        let result = [] as File[];
1,165✔
395
        for (let filePath in this.files) {
1,165✔
396
            let file = this.files[filePath];
1,385✔
397
            //is this file part of a scope
398
            if (!this.getFirstScopeForFile(file)) {
1,385✔
399
                //no scopes reference this file. add it to the list
400
                result.push(file);
46✔
401
            }
402
        }
403
        return result;
1,165✔
404
    }
405

406
    /**
407
     * Get the list of errors for the entire program. It's calculated on the fly
408
     * by walking through every file, so call this sparingly.
409
     */
410
    public getDiagnostics() {
411
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
1,165✔
412

413
            let diagnostics = [...this.diagnostics];
1,165✔
414

415
            //get the diagnostics from all scopes
416
            for (let scopeName in this.scopes) {
1,165✔
417
                let scope = this.scopes[scopeName];
2,332✔
418
                diagnostics.push(
2,332✔
419
                    ...scope.getDiagnostics()
420
                );
421
            }
422

423
            //get the diagnostics from all unreferenced files
424
            let unreferencedFiles = this.getUnreferencedFiles();
1,165✔
425
            for (let file of unreferencedFiles) {
1,165✔
426
                diagnostics.push(
46✔
427
                    ...file.getDiagnostics()
428
                );
429
            }
430
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
1,165✔
431
                //filter out diagnostics based on our diagnostic filters
432
                let finalDiagnostics = this.diagnosticFilterer.filter({
1,165✔
433
                    ...this.options,
434
                    rootDir: this.options.rootDir
435
                }, diagnostics);
436
                return finalDiagnostics;
1,165✔
437
            });
438

439
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,165✔
440
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,165✔
441
            });
442

443
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
1,165✔
444
            return filteredDiagnostics;
1,165✔
445
        });
446
    }
447

448
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
449
        this.diagnostics.push(...diagnostics);
55✔
450
    }
451

452
    /**
453
     * Determine if the specified file is loaded in this program right now.
454
     * @param filePath the absolute or relative path to the file
455
     * @param normalizePath should the provided path be normalized before use
456
     */
457
    public hasFile(filePath: string, normalizePath = true) {
1,818✔
458
        return !!this.getFile(filePath, normalizePath);
1,818✔
459
    }
460

461
    public getPkgPath(...args: any[]): any { //eslint-disable-line
462
        throw new Error('Not implemented');
×
463
    }
464

465
    /**
466
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
467
     */
468
    public getScopeByName(scopeName: string): Scope | undefined {
469
        if (!scopeName) {
35!
470
            return undefined;
×
471
        }
472
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
473
        //so it's safe to run the standardizePkgPath method
474
        scopeName = s`${scopeName}`;
35✔
475
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
86✔
476
        return this.scopes[key!];
35✔
477
    }
478

479
    /**
480
     * Return all scopes
481
     */
482
    public getScopes() {
483
        return Object.values(this.scopes);
9✔
484
    }
485

486
    /**
487
     * Find the scope for the specified component
488
     */
489
    public getComponentScope(componentName: string) {
490
        return this.getComponent(componentName)?.scope;
197✔
491
    }
492

493
    /**
494
     * Update internal maps with this file reference
495
     */
496
    private assignFile<T extends BscFile = BscFile>(file: T) {
497
        this.files[file.srcPath.toLowerCase()] = file;
1,776✔
498
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,776✔
499
        return file;
1,776✔
500
    }
501

502
    /**
503
     * Remove this file from internal maps
504
     */
505
    private unassignFile<T extends BscFile = BscFile>(file: T) {
506
        delete this.files[file.srcPath.toLowerCase()];
156✔
507
        delete this.pkgMap[file.pkgPath.toLowerCase()];
156✔
508
        return file;
156✔
509
    }
510

511
    /**
512
     * Load a file into the program. If that file already exists, it is replaced.
513
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
514
     * @param srcPath the file path relative to the root dir
515
     * @param fileContents the file contents
516
     * @deprecated use `setFile` instead
517
     */
518
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
519
    /**
520
     * Load a file into the program. If that file already exists, it is replaced.
521
     * @param fileEntry an object that specifies src and dest for the file.
522
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
523
     * @deprecated use `setFile` instead
524
     */
525
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
526
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
527
        return this.setFile<T>(fileParam as any, fileContents);
1✔
528
    }
529

530
    /**
531
     * Load a file into the program. If that file already exists, it is replaced.
532
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
533
     * @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:/`)
534
     * @param fileContents the file contents
535
     */
536
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
537
    /**
538
     * Load a file into the program. If that file already exists, it is replaced.
539
     * @param fileEntry an object that specifies src and dest for the file.
540
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
541
     */
542
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
543
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
544
        //normalize the file paths
545
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,780✔
546

547
        //namespace contributions for the new/replaced file may differ; force the
548
        //program-level contributors map to rebuild on next query
549
        this.invalidateNamespaceContributorCache();
1,780✔
550

551
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,780✔
552
            //if the file is already loaded, remove it
553
            if (this.hasFile(srcPath)) {
1,780✔
554
                this.removeFile(srcPath);
139✔
555
            }
556
            let fileExtension = path.extname(srcPath).toLowerCase();
1,780✔
557
            let file: BscFile | undefined;
558

559
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,780✔
560
                //add the file to the program
561
                const brsFile = this.assignFile(
1,497✔
562
                    new BrsFile(srcPath, pkgPath, this)
563
                );
564

565
                //add file to the `source` dependency list
566
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,497✔
567
                    this.createSourceScope();
1,307✔
568
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,307✔
569
                }
570

571
                let sourceObj: SourceObj = {
1,497✔
572
                    //TODO remove `pathAbsolute` in v1
573
                    pathAbsolute: srcPath,
574
                    srcPath: srcPath,
575
                    source: fileContents
576
                };
577
                this.plugins.emit('beforeFileParse', sourceObj);
1,497✔
578

579
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,497✔
580
                    brsFile.parse(sourceObj.source);
1,497✔
581
                });
582

583
                //notify plugins that this file has finished parsing
584
                this.plugins.emit('afterFileParse', brsFile);
1,497✔
585

586
                file = brsFile;
1,497✔
587

588
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,497✔
589

590
            } else if (
283✔
591
                //is xml file
592
                fileExtension === '.xml' &&
565✔
593
                //resides in the components folder (Roku will only parse xml files in the components folder)
594
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
595
            ) {
596
                //add the file to the program
597
                const xmlFile = this.assignFile(
279✔
598
                    new XmlFile(srcPath, pkgPath, this)
599
                );
600

601
                let sourceObj: SourceObj = {
279✔
602
                    //TODO remove `pathAbsolute` in v1
603
                    pathAbsolute: srcPath,
604
                    srcPath: srcPath,
605
                    source: fileContents
606
                };
607
                this.plugins.emit('beforeFileParse', sourceObj);
279✔
608

609
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
279✔
610
                    xmlFile.parse(sourceObj.source);
279✔
611
                });
612

613
                //notify plugins that this file has finished parsing
614
                this.plugins.emit('afterFileParse', xmlFile);
279✔
615

616
                file = xmlFile;
279✔
617

618
                //create a new scope for this xml file
619
                let scope = new XmlScope(xmlFile, this);
279✔
620
                this.addScope(scope);
279✔
621

622
                //register this compoent now that we have parsed it and know its component name
623
                this.registerComponent(xmlFile, scope);
279✔
624

625
                //notify plugins that the scope is created and the component is registered
626
                this.plugins.emit('afterScopeCreate', scope);
279✔
627
            } else {
628
                //TODO do we actually need to implement this? Figure out how to handle img paths
629
                // let genericFile = this.files[srcPath] = <any>{
630
                //     srcPath: srcPath,
631
                //     pkgPath: pkgPath,
632
                //     wasProcessed: true
633
                // } as File;
634
                // file = <any>genericFile;
635
            }
636
            return file;
1,780✔
637
        });
638
        return file as T;
1,780✔
639
    }
640

641
    /**
642
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
643
     * @param fileParam an object representing file paths
644
     * @param rootDir must be a pre-normalized path
645
     */
646
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
647
        let srcPath: string | undefined;
648
        let pkgPath: string | undefined;
649

650
        assert.ok(fileParam, 'fileParam is required');
1,787✔
651

652
        //lift the srcPath and pkgPath vars from the incoming param
653
        if (typeof fileParam === 'string') {
1,787✔
654
            fileParam = this.removePkgPrefix(fileParam);
1,293✔
655
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,293✔
656
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,293✔
657
        } else {
658
            let param: any = fileParam;
494✔
659

660
            if (param.src) {
494✔
661
                srcPath = s`${param.src}`;
491✔
662
            }
663
            if (param.srcPath) {
494✔
664
                srcPath = s`${param.srcPath}`;
2✔
665
            }
666
            if (param.dest) {
494✔
667
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
491✔
668
            }
669
            if (param.pkgPath) {
494✔
670
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
671
            }
672
        }
673

674
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
675
        if (!srcPath) {
1,787✔
676
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
677
        }
678
        //coerce srcPath to an absolute path
679
        if (!path.isAbsolute(srcPath)) {
1,787✔
680
            srcPath = util.standardizePath(srcPath);
1✔
681
        }
682

683
        //if there's no pkgPath, compute relative path from rootDir
684
        if (!pkgPath) {
1,787✔
685
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
686
        }
687

688
        assert.ok(srcPath, 'fileEntry.src is required');
1,787✔
689
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,787✔
690

691
        return {
1,787✔
692
            srcPath: srcPath,
693
            //remove leading slash from pkgPath
694
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
695
        };
696
    }
697

698
    /**
699
     * Remove any leading `pkg:/` found in the path
700
     */
701
    private removePkgPrefix(path: string) {
702
        return path.replace(/^pkg:\//i, '');
1,786✔
703
    }
704

705
    /**
706
     * Ensure source scope is created.
707
     * Note: automatically called internally, and no-op if it exists already.
708
     */
709
    public createSourceScope() {
710
        if (!this.scopes.source) {
1,787✔
711
            const sourceScope = new Scope('source', this, 'scope:source');
1,334✔
712
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,334✔
713
            this.addScope(sourceScope);
1,334✔
714
            this.plugins.emit('afterScopeCreate', sourceScope);
1,334✔
715
        }
716
    }
717

718
    /**
719
     * Find the file by its absolute path. This is case INSENSITIVE, since
720
     * Roku is a case insensitive file system. It is an error to have multiple files
721
     * with the same path with only case being different.
722
     * @param srcPath the absolute path to the file
723
     * @deprecated use `getFile` instead, which auto-detects the path type
724
     */
725
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
726
        srcPath = s`${srcPath}`;
×
727
        for (let filePath in this.files) {
×
728
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
729
                return this.files[filePath] as T;
×
730
            }
731
        }
732
    }
733

734
    /**
735
     * Get a list of files for the given (platform-normalized) pkgPath array.
736
     * Missing files are just ignored.
737
     * @deprecated use `getFiles` instead, which auto-detects the path types
738
     */
739
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
740
        return pkgPaths
×
741
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
742
            .filter(file => file !== undefined) as T;
×
743
    }
744

745
    /**
746
     * Get a file with the specified (platform-normalized) pkg path.
747
     * If not found, return undefined
748
     * @deprecated use `getFile` instead, which auto-detects the path type
749
     */
750
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
751
        return this.pkgMap[pkgPath.toLowerCase()] as T;
500✔
752
    }
753

754
    /**
755
     * Remove a set of files from the program
756
     * @param srcPaths can be an array of srcPath or destPath strings
757
     * @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
758
     */
759
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
760
        for (let srcPath of srcPaths) {
1✔
761
            this.removeFile(srcPath, normalizePath);
1✔
762
        }
763
    }
764

765
    /**
766
     * Remove a file from the program
767
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
768
     * @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
769
     */
770
    public removeFile(filePath: string, normalizePath = true) {
152✔
771
        this.logger.debug('Program.removeFile()', filePath);
156✔
772

773
        //namespace contributions may have included this file; force the program-level
774
        //contributors map to rebuild on next query
775
        this.invalidateNamespaceContributorCache();
156✔
776

777
        let file = this.getFile(filePath, normalizePath);
156✔
778
        if (file) {
156!
779
            this.plugins.emit('beforeFileDispose', file);
156✔
780

781
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
782
            let scope = this.scopes[file.pkgPath];
156✔
783
            if (scope) {
156✔
784
                this.plugins.emit('beforeScopeDispose', scope);
11✔
785
                scope.dispose();
11✔
786
                //notify dependencies of this scope that it has been removed
787
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
788
                delete this.scopes[file.pkgPath];
11✔
789
                this.plugins.emit('afterScopeDispose', scope);
11✔
790
            }
791
            //remove the file from the program
792
            this.unassignFile(file);
156✔
793

794
            this.dependencyGraph.remove(file.dependencyGraphKey);
156✔
795

796
            //if this is a pkg:/source file, notify the `source` scope that it has changed
797
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
156✔
798
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
105✔
799
            }
800

801
            //if this is a component, remove it from our components map
802
            if (isXmlFile(file)) {
156✔
803
                this.unregisterComponent(file);
11✔
804
            }
805
            //dispose file
806
            file?.dispose();
156!
807
            this.plugins.emit('afterFileDispose', file);
156✔
808
        }
809
    }
810

811
    /**
812
     * Counter used to track which validation run is being logged
813
     */
814
    private validationRunSequence = 1;
1,793✔
815

816
    /**
817
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
818
     */
819
    private validationMinSyncDuration = 75;
1,793✔
820

821
    private validatePromise: Promise<void> | undefined;
822

823
    /**
824
     * Traverse the entire project, and validate all scopes
825
     */
826
    public validate(): void;
827
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
828
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
829
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
830
        const validationRunId = this.validationRunSequence++;
1,122✔
831
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
1,122!
832

833
        let previousValidationPromise = this.validatePromise;
1,122✔
834
        const deferred = new Deferred();
1,122✔
835

836
        if (options?.async) {
1,122✔
837
            //we're async, so create a new promise chain to resolve after this validation is done
838
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
164✔
839
                return deferred.promise;
164✔
840
            });
841

842
            //we are not async but there's a pending promise, then we cannot run this validation
843
        } else if (previousValidationPromise !== undefined) {
958!
844
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
845
        }
846

847
        if (options?.async) {
1,122✔
848
            //we're async, so create a new promise chain to resolve after this validation is done
849
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
164✔
850
                return deferred.promise;
164✔
851
            });
852

853
            //we are not async but there's a pending promise, then we cannot run this validation
854
        } else if (previousValidationPromise !== undefined) {
958!
855
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
856
        }
857

858
        const sequencer = new Sequencer({
1,122✔
859
            name: 'program.validate',
860
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
6,732✔
861
            minSyncDuration: this.validationMinSyncDuration
862
        });
863

864
        let beforeProgramValidateWasEmitted = false;
1,122✔
865

866
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
867
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
868
        sequencer
1,122✔
869
            .once(() => {
870
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
871
                if (options?.async) {
1,122✔
872
                    return previousValidationPromise;
164✔
873
                }
874
            })
875
            .once(() => {
876
                this.diagnostics = [];
1,119✔
877
                this.plugins.emit('beforeProgramValidate', this);
1,119✔
878
                beforeProgramValidateWasEmitted = true;
1,119✔
879
            })
880
            .forEach(() => Object.values(this.files), (file) => {
1,119✔
881
                if (!file.isValidated) {
1,385✔
882
                    this.plugins.emit('beforeFileValidate', {
1,322✔
883
                        program: this,
884
                        file: file
885
                    });
886

887
                    //emit an event to allow plugins to contribute to the file validation process
888
                    this.plugins.emit('onFileValidate', {
1,322✔
889
                        program: this,
890
                        file: file
891
                    });
892
                    //call file.validate() IF the file has that function defined
893
                    file.validate?.();
1,322!
894
                    file.isValidated = true;
1,321✔
895

896
                    this.plugins.emit('afterFileValidate', file);
1,321✔
897
                }
898
            })
899
            .forEach(Object.values(this.scopes), (scope) => {
900
                scope.linkSymbolTable();
2,300✔
901
                scope.validate();
2,300✔
902
                scope.unlinkSymbolTable();
2,298✔
903
            })
904
            .once(() => {
905
                this.detectDuplicateComponentNames();
1,114✔
906
            })
907
            .onCancel(() => {
908
                timeEnd('cancelled');
8✔
909
            })
910
            .onSuccess(() => {
911
                timeEnd();
1,114✔
912
            })
913
            .onComplete(() => {
914
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
915
                if (beforeProgramValidateWasEmitted) {
1,122✔
916
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
1,119✔
917
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
1,119✔
918
                }
919

920
                //regardless of the success of the validation, mark this run as complete
921
                deferred.resolve();
1,122✔
922
                //clear the validatePromise which means we're no longer running a validation
923
                this.validatePromise = undefined;
1,122✔
924
            });
925

926
        //run the sequencer in async mode if enabled
927
        if (options?.async) {
1,122✔
928
            return sequencer.run();
164✔
929

930
            //run the sequencer in sync mode
931
        } else {
932
            return sequencer.runSync();
958✔
933
        }
934
    }
935

936
    /**
937
     * Flag all duplicate component names
938
     */
939
    private detectDuplicateComponentNames() {
940
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,114✔
941
            const file = this.files[filePath];
1,376✔
942
            //if this is an XmlFile, and it has a valid `componentName` property
943
            if (isXmlFile(file) && file.componentName?.text) {
1,376✔
944
                let lowerName = file.componentName.text.toLowerCase();
245✔
945
                if (!map[lowerName]) {
245✔
946
                    map[lowerName] = [];
242✔
947
                }
948
                map[lowerName].push(file);
245✔
949
            }
950
            return map;
1,376✔
951
        }, {});
952

953
        for (let name in componentsByName) {
1,114✔
954
            const xmlFiles = componentsByName[name];
242✔
955
            //add diagnostics for every duplicate component with this name
956
            if (xmlFiles.length > 1) {
242✔
957
                for (let xmlFile of xmlFiles) {
3✔
958
                    const { componentName } = xmlFile;
6✔
959
                    this.diagnostics.push({
6✔
960
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
961
                        range: xmlFile.componentName.range,
962
                        file: xmlFile,
963
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
964
                            return {
6✔
965
                                location: util.createLocation(
966
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
967
                                    x.componentName.range
968
                                ),
969
                                message: 'Also defined here'
970
                            };
971
                        })
972
                    });
973
                }
974
            }
975
        }
976
    }
977

978
    /**
979
     * Get the files for a list of filePaths
980
     * @param filePaths can be an array of srcPath or a destPath strings
981
     * @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
982
     */
983
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
28✔
984
        return filePaths
28✔
985
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
986
            .filter(file => file !== undefined) as T[];
30✔
987
    }
988

989
    /**
990
     * Get the file at the given path
991
     * @param filePath can be a srcPath or a destPath
992
     * @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
993
     */
994
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
7,309✔
995
        if (typeof filePath !== 'string') {
9,313✔
996
            return undefined;
1,981✔
997
        } else if (path.isAbsolute(filePath)) {
7,332✔
998
            return this.files[
3,839✔
999
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,839✔
1000
            ] as T;
1001
        } else {
1002
            return this.pkgMap[
3,493✔
1003
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,493!
1004
            ] as T;
1005
        }
1006
    }
1007

1008
    /**
1009
     * Get a list of all scopes the file is loaded into
1010
     * @param file the file
1011
     */
1012
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1013

1014
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
633✔
1015

1016
        let result = [] as Scope[];
633✔
1017
        if (resolvedFile) {
633✔
1018
            for (let key in this.scopes) {
632✔
1019
                let scope = this.scopes[key];
1,314✔
1020

1021
                if (scope.hasFile(resolvedFile)) {
1,314✔
1022
                    result.push(scope);
643✔
1023
                }
1024
            }
1025
        }
1026
        return result;
633✔
1027
    }
1028

1029
    /**
1030
     * Get the first found scope for a file.
1031
     */
1032
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1033
        for (let key in this.scopes) {
3,073✔
1034
            let scope = this.scopes[key];
7,501✔
1035

1036
            if (scope.hasFile(file)) {
7,501✔
1037
                return scope;
2,931✔
1038
            }
1039
        }
1040
    }
1041

1042
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1043
        let results = new Map<Statement, FileLink<Statement>>();
46✔
1044
        const filesSearched = new Set<BrsFile>();
46✔
1045
        let lowerNamespaceName = namespaceName?.toLowerCase();
46✔
1046
        let lowerName = name?.toLowerCase();
46!
1047
        //look through all files in scope for matches
1048
        for (const scope of this.getScopesForFile(originFile)) {
46✔
1049
            for (const file of scope.getAllFiles()) {
46✔
1050
                if (isXmlFile(file) || filesSearched.has(file)) {
52✔
1051
                    continue;
3✔
1052
                }
1053
                filesSearched.add(file);
49✔
1054

1055
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
49✔
1056
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
111✔
1057
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
111✔
1058
                        if (!results.has(statement)) {
42!
1059
                            results.set(statement, { item: statement, file: file });
42✔
1060
                        }
1061
                    }
1062
                }
1063
            }
1064
        }
1065
        return [...results.values()];
46✔
1066
    }
1067

1068
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1069
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1070
        const filesSearched = new Set<BrsFile>();
8✔
1071

1072
        //get all function names for the xml file and parents
1073
        let funcNames = new Set<string>();
8✔
1074
        let currentScope = scope;
8✔
1075
        while (isXmlScope(currentScope)) {
8✔
1076
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1077
                if (!filterName || name === filterName) {
14!
1078
                    funcNames.add(name);
14✔
1079
                }
1080
            }
1081
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1082
        }
1083

1084
        //look through all files in scope for matches
1085
        for (const file of scope.getOwnFiles()) {
8✔
1086
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1087
                continue;
8✔
1088
            }
1089
            filesSearched.add(file);
8✔
1090

1091
            for (const statement of file.parser.references.functionStatements) {
8✔
1092
                if (funcNames.has(statement.name.text)) {
13!
1093
                    if (!results.has(statement)) {
13!
1094
                        results.set(statement, { item: statement, file: file });
13✔
1095
                    }
1096
                }
1097
            }
1098
        }
1099
        return [...results.values()];
8✔
1100
    }
1101

1102
    /**
1103
     * Find all available completion items at the given position
1104
     * @param filePath can be a srcPath or a destPath
1105
     * @param position the position (line & column) where completions should be found
1106
     */
1107
    public getCompletions(filePath: string, position: Position) {
1108
        let file = this.getFile(filePath);
77✔
1109
        if (!file) {
77!
1110
            return [];
×
1111
        }
1112

1113
        //find the scopes for this file
1114
        let scopes = this.getScopesForFile(file);
77✔
1115

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

1119
        const event: ProvideCompletionsEvent = {
77✔
1120
            program: this,
1121
            file: file,
1122
            scopes: scopes,
1123
            position: position,
1124
            completions: []
1125
        };
1126

1127
        this.plugins.emit('beforeProvideCompletions', event);
77✔
1128

1129
        this.plugins.emit('provideCompletions', event);
77✔
1130

1131
        this.plugins.emit('afterProvideCompletions', event);
77✔
1132

1133
        return event.completions;
77✔
1134
    }
1135

1136
    /**
1137
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1138
     */
1139
    public getWorkspaceSymbols() {
1140
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1141
            program: this,
1142
            workspaceSymbols: []
1143
        };
1144
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1145
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1146
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1147
        return event.workspaceSymbols;
22✔
1148
    }
1149

1150
    /**
1151
     * Given a position in a file, if the position is sitting on some type of identifier,
1152
     * go to the definition of that identifier (where this thing was first defined)
1153
     */
1154
    public getDefinition(srcPath: string, position: Position): Location[] {
1155
        let file = this.getFile(srcPath);
19✔
1156
        if (!file) {
19!
1157
            return [];
×
1158
        }
1159

1160
        const event: ProvideDefinitionEvent = {
19✔
1161
            program: this,
1162
            file: file,
1163
            position: position,
1164
            definitions: []
1165
        };
1166

1167
        this.plugins.emit('beforeProvideDefinition', event);
19✔
1168
        this.plugins.emit('provideDefinition', event);
19✔
1169
        this.plugins.emit('afterProvideDefinition', event);
19✔
1170
        return event.definitions;
19✔
1171
    }
1172

1173
    /**
1174
     * Get hover information for a file and position
1175
     */
1176
    public getHover(srcPath: string, position: Position): Hover[] {
1177
        let file = this.getFile(srcPath);
30✔
1178
        let result: Hover[];
1179
        if (file) {
30!
1180
            const event = {
30✔
1181
                program: this,
1182
                file: file,
1183
                position: position,
1184
                scopes: this.getScopesForFile(file),
1185
                hovers: []
1186
            } as ProvideHoverEvent;
1187
            this.plugins.emit('beforeProvideHover', event);
30✔
1188
            this.plugins.emit('provideHover', event);
30✔
1189
            this.plugins.emit('afterProvideHover', event);
30✔
1190
            result = event.hovers;
30✔
1191
        }
1192

1193
        return result ?? [];
30!
1194
    }
1195

1196
    /**
1197
     * Get full list of document symbols for a file
1198
     * @param srcPath path to the file
1199
     */
1200
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1201
        let file = this.getFile(srcPath);
24✔
1202
        if (file) {
24!
1203
            const event: ProvideDocumentSymbolsEvent = {
24✔
1204
                program: this,
1205
                file: file,
1206
                documentSymbols: []
1207
            };
1208
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1209
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1210
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1211
            return event.documentSymbols;
24✔
1212
        } else {
1213
            return undefined;
×
1214
        }
1215
    }
1216

1217
    /**
1218
     * Get the selection ranges for the given positions in a file. Used for expand/shrink selection.
1219
     * @param srcPath path to the file
1220
     * @param positions the positions to get selection ranges for
1221
     */
1222
    public getSelectionRanges(srcPath: string, positions: Position[]): SelectionRange[] {
1223
        const file = this.getFile(srcPath);
15✔
1224
        if (file) {
15✔
1225
            const event: ProvideSelectionRangesEvent = {
14✔
1226
                program: this,
1227
                file: file,
1228
                positions: positions,
1229
                selectionRanges: []
1230
            };
1231
            this.plugins.emit('beforeProvideSelectionRanges', event);
14✔
1232
            this.plugins.emit('provideSelectionRanges', event);
14✔
1233
            this.plugins.emit('afterProvideSelectionRanges', event);
14✔
1234
            return event.selectionRanges;
14✔
1235
        }
1236
        return [];
1✔
1237
    }
1238

1239
    /**
1240
     * Get inlay hints for the given file and range.
1241
     */
1242
    public getInlayHints(srcPath: string, range: Range): InlayHint[] {
1243
        const file = this.getFile(srcPath);
11✔
1244
        if (file) {
11!
1245
            const event: ProvideInlayHintsEvent = {
11✔
1246
                program: this,
1247
                file: file,
1248
                range: range,
1249
                scopes: this.getScopesForFile(file),
1250
                inlayHints: []
1251
            };
1252
            this.plugins.emit('beforeProvideInlayHints', event);
11✔
1253
            this.plugins.emit('provideInlayHints', event);
11✔
1254
            this.plugins.emit('afterProvideInlayHints', event);
11✔
1255
            return event.inlayHints;
11✔
1256
        }
1257
        return [];
×
1258
    }
1259

1260
    /**
1261
     * Compute code actions for the given file and range
1262
     */
1263
    public getCodeActions(srcPath: string, range: Range) {
1264
        const codeActions = [] as CodeAction[];
71✔
1265
        const file = this.getFile(srcPath);
71✔
1266
        if (file) {
71✔
1267
            const diagnostics = this
70✔
1268
                //get all current diagnostics (filtered by diagnostic filters)
1269
                .getDiagnostics()
1270
                //only keep diagnostics related to this file
1271
                .filter(x => x.file === file)
114✔
1272
                //only keep diagnostics that touch this range
1273
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
91✔
1274

1275
            const scopes = this.getScopesForFile(file);
70✔
1276

1277
            this.plugins.emit('onGetCodeActions', {
70✔
1278
                program: this,
1279
                file: file,
1280
                range: range,
1281
                diagnostics: diagnostics,
1282
                scopes: scopes,
1283
                codeActions: codeActions
1284
            });
1285
        }
1286
        return codeActions;
71✔
1287
    }
1288

1289
    /**
1290
     * Compute "source fix all" code actions for the given file.
1291
     * Fires the `onGetSourceFixAllCodeActions` plugin event with all diagnostics for the file (no range filter),
1292
     * then converts each contributed SourceFixAllCodeAction into an LSP CodeAction.
1293
     */
1294
    public getSourceFixAllCodeActions(srcPath: string): CodeAction[] {
1295
        const actions: SourceFixAllCodeAction[] = [];
×
1296
        const file = this.getFile(srcPath);
×
1297
        if (file) {
×
1298
            const diagnostics = this
×
1299
                .getDiagnostics()
1300
                .filter(x => x.file === file);
×
1301
            const scopes = this.getScopesForFile(file);
×
1302
            this.plugins.emit('onGetSourceFixAllCodeActions', {
×
1303
                program: this,
1304
                file: file,
1305
                diagnostics: diagnostics,
1306
                scopes: scopes,
1307
                actions: actions
1308
            } as OnGetSourceFixAllCodeActionsEvent);
1309
        }
1310
        return actions.map(action => codeActionUtil.createCodeAction({
×
1311
            ...action,
1312
            kind: action.kind ?? 'source.fixAll.brighterscript' as any
×
1313
        }));
1314
    }
1315

1316
    /**
1317
     * Get semantic tokens for the specified file
1318
     */
1319
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1320
        const file = this.getFile(srcPath);
16✔
1321
        if (file) {
16!
1322
            const result = [] as SemanticToken[];
16✔
1323
            this.plugins.emit('onGetSemanticTokens', {
16✔
1324
                program: this,
1325
                file: file,
1326
                scopes: this.getScopesForFile(file),
1327
                semanticTokens: result
1328
            });
1329
            return result;
16✔
1330
        }
1331
    }
1332

1333
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1334
        let file: BrsFile = this.getFile(filePath);
188✔
1335
        if (!file || !isBrsFile(file)) {
188✔
1336
            return [];
3✔
1337
        }
1338
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1339
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1340
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1341
    }
1342

1343
    public getReferences(srcPath: string, position: Position): Location[] {
1344
        //find the file
1345
        let file = this.getFile(srcPath);
4✔
1346
        if (!file) {
4!
1347
            return null;
×
1348
        }
1349

1350
        const event: ProvideReferencesEvent = {
4✔
1351
            program: this,
1352
            file: file,
1353
            position: position,
1354
            references: []
1355
        };
1356

1357
        this.plugins.emit('beforeProvideReferences', event);
4✔
1358
        this.plugins.emit('provideReferences', event);
4✔
1359
        this.plugins.emit('afterProvideReferences', event);
4✔
1360

1361
        return event.references;
4✔
1362
    }
1363

1364
    /**
1365
     * Get a list of all script imports, relative to the specified pkgPath
1366
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1367
     */
1368
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1369
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1370

1371
        let result = [] as CompletionItem[];
3✔
1372
        /**
1373
         * hashtable to prevent duplicate results
1374
         */
1375
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1376

1377
        //restrict to only .brs files
1378
        for (let key in this.files) {
3✔
1379
            let file = this.files[key];
4✔
1380
            if (
4✔
1381
                //is a BrightScript or BrighterScript file
1382
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1383
                //this file is not the current file
1384
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1385
            ) {
1386
                //add the relative path
1387
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1388
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1389
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1390
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1391
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1392
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1393

1394
                    result.push({
3✔
1395
                        label: relativePath,
1396
                        detail: file.srcPath,
1397
                        kind: CompletionItemKind.File,
1398
                        textEdit: {
1399
                            newText: relativePath,
1400
                            range: scriptImport.filePathRange
1401
                        }
1402
                    });
1403

1404
                    //add the absolute path
1405
                    result.push({
3✔
1406
                        label: filePkgPath,
1407
                        detail: file.srcPath,
1408
                        kind: CompletionItemKind.File,
1409
                        textEdit: {
1410
                            newText: filePkgPath,
1411
                            range: scriptImport.filePathRange
1412
                        }
1413
                    });
1414
                }
1415
            }
1416
        }
1417
        return result;
3✔
1418
    }
1419

1420
    /**
1421
     * Transpile a single file and get the result as a string.
1422
     * This does not write anything to the file system.
1423
     *
1424
     * This should only be called by `LanguageServer`.
1425
     * Internal usage should call `_getTranspiledFileContents` instead.
1426
     * @param filePath can be a srcPath or a destPath
1427
     */
1428
    public async getTranspiledFileContents(filePath: string) {
1429
        const file = this.getFile(filePath);
4✔
1430
        const fileMap: FileObj[] = [{
4✔
1431
            src: file.srcPath,
1432
            dest: file.pkgPath
1433
        }];
1434
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1435
        const result = this._getTranspiledFileContents(
4✔
1436
            file
1437
        );
1438
        await this._chainInputSourceMap(result, file);
4✔
1439
        this.afterProgramTranspile(entries, astEditor);
4✔
1440
        return result;
4✔
1441
    }
1442

1443
    /**
1444
     * Internal function used to transpile files.
1445
     * This does not write anything to the file system
1446
     */
1447
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1448
        const editor = new AstEditor();
357✔
1449
        this.plugins.emit('beforeFileTranspile', {
357✔
1450
            program: this,
1451
            file: file,
1452
            outputPath: outputPath,
1453
            editor: editor
1454
        });
1455

1456
        //if we have any edits, assume the file needs to be transpiled
1457
        if (editor.hasChanges) {
357✔
1458
            //use the `editor` because it'll track the previous value for us and revert later on
1459
            editor.setProperty(file, 'needsTranspiled', true);
82✔
1460
        }
1461

1462
        //transpile the file
1463
        const result = file.transpile();
357✔
1464

1465
        //generate the typedef if enabled
1466
        let typedef: string;
1467
        if (isBrsFile(file) && this.options.emitDefinitions) {
357✔
1468
            typedef = file.getTypedef();
2✔
1469
        }
1470

1471
        const event: AfterFileTranspileEvent = {
357✔
1472
            program: this,
1473
            file: file,
1474
            outputPath: outputPath,
1475
            editor: editor,
1476
            code: result.code,
1477
            map: result.map,
1478
            typedef: typedef
1479
        };
1480
        this.plugins.emit('afterFileTranspile', event);
357✔
1481

1482
        //undo all `editor` edits that may have been applied to this file.
1483
        editor.undoAll();
357✔
1484

1485
        return {
357✔
1486
            srcPath: file.srcPath,
1487
            pkgPath: file.pkgPath,
1488
            code: event.code,
1489
            map: event.map,
1490
            typedef: event.typedef
1491
        };
1492
    }
1493

1494
    /**
1495
     * If the file has an incoming sourcemap (from a prebuild step), chain it into the
1496
     * generated sourcemap so the output map traces all the way back to the original source.
1497
     * This is async because SourceMapConsumer requires async initialisation in source-map v0.7.
1498
     */
1499
    private async _chainInputSourceMap(result: FileTranspileResult, file: BscFile): Promise<void> {
1500
        if (result.map) {
61✔
1501
            const inputMap = await util.resolveInputSourceMap(file.fileContents ?? '', file.srcPath);
29!
1502
            if (inputMap) {
29✔
1503
                await util.applySourceMap(result.map, inputMap, file.srcPath);
8✔
1504
            }
1505
        }
1506
    }
1507

1508
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1509
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1510
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
61✔
1511
            collection[s`${entry.src}`] = entry;
44✔
1512
            return collection;
44✔
1513
        }, {});
1514

1515
        const getOutputPath = (file: BscFile) => {
61✔
1516
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
125✔
1517
            if (!filePathObj) {
125✔
1518
                //this file has been added in-memory, from a plugin, for example
1519
                filePathObj = {
47✔
1520
                    //add an interpolated src path (since it doesn't actually exist in memory)
1521
                    src: `bsc:/${file.pkgPath}`,
1522
                    dest: file.pkgPath
1523
                };
1524
            }
1525
            //replace the file extension
1526
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
125✔
1527
            //prepend the staging folder path
1528
            outputPath = s`${stagingDir}/${outputPath}`;
125✔
1529
            return outputPath;
125✔
1530
        };
1531

1532
        const entries = Object.values(this.files)
61✔
1533
            //only include the files from fileEntries
1534
            .filter(file => !!mappedFileEntries[file.srcPath])
63✔
1535
            .map(file => {
1536
                return {
42✔
1537
                    file: file,
1538
                    outputPath: getOutputPath(file)
1539
                };
1540
            })
1541
            //sort the entries to make transpiling more deterministic
1542
            .sort((a, b) => {
1543
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1544
            });
1545

1546
        const astEditor = new AstEditor();
61✔
1547

1548
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
61✔
1549
        return {
61✔
1550
            entries: entries,
1551
            getOutputPath: getOutputPath,
1552
            astEditor: astEditor
1553
        };
1554
    }
1555

1556
    public async transpile(fileEntries: FileObj[], stagingDir: string) {
1557
        const { entries, getOutputPath, astEditor } = this.beforeProgramTranspile(fileEntries, stagingDir);
56✔
1558

1559
        const processedFiles = new Set<string>();
56✔
1560

1561
        const transpileFile = async (srcPath: string, outputPath?: string) => {
56✔
1562
            //find the file in the program
1563
            const file = this.getFile(srcPath);
59✔
1564
            //mark this file as processed so we don't process it more than once
1565
            processedFiles.add(outputPath?.toLowerCase());
59!
1566

1567
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
59✔
1568
                //skip transpiling typedef files
1569
                if (isBrsFile(file) && file.isTypedef) {
58✔
1570
                    return;
1✔
1571
                }
1572

1573
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
57✔
1574
                await this._chainInputSourceMap(fileTranspileResult, file);
57✔
1575

1576
                //make sure the full dir path exists
1577
                await fsExtra.ensureDir(path.dirname(outputPath));
57✔
1578

1579
                if (await fsExtra.pathExists(outputPath)) {
57!
1580
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1581
                }
1582
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, this.serializeSourceMap(fileTranspileResult.map, outputPath)) : null;
57✔
1583
                await Promise.all([
57✔
1584
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1585
                    writeMapPromise
1586
                ]);
1587

1588
                if (fileTranspileResult.typedef) {
57✔
1589
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1590
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1591
                }
1592
            }
1593
        };
1594

1595
        let promises = entries.map(async (entry) => {
56✔
1596
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
36!
1597
        });
1598

1599
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1600
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
56✔
1601
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
55✔
1602
        }
1603
        await Promise.all(promises);
56✔
1604

1605
        //transpile any new files that plugins added since the start of this transpile process
1606
        do {
56✔
1607
            promises = [];
76✔
1608
            for (const key in this.files) {
76✔
1609
                const file = this.files[key];
83✔
1610
                //this is a new file
1611
                const outputPath = getOutputPath(file);
83✔
1612
                if (!processedFiles.has(outputPath?.toLowerCase())) {
83!
1613
                    promises.push(
23✔
1614
                        transpileFile(file?.srcPath, outputPath)
69!
1615
                    );
1616
                }
1617
            }
1618
            if (promises.length > 0) {
76✔
1619
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1620
                await Promise.all(promises);
20✔
1621
            }
1622
        }
1623
        while (promises.length > 0);
1624
        this.afterProgramTranspile(entries, astEditor);
56✔
1625
    }
1626

1627
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1628
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
60✔
1629
        astEditor.undoAll();
60✔
1630
    }
1631

1632
    /**
1633
     * Serialize a source map to a JSON string, handling relativeSourceMaps and sourceRoot.
1634
     *
1635
     * relativeSourceMaps:false — legacy: sources[] has absolute paths (rootDir swapped for sourceRoot
1636
     * if set); map's sourceRoot field is never written.
1637
     *
1638
     * relativeSourceMaps:true, no sourceRoot — sources[] is relative to the map file directory.
1639
     *
1640
     * relativeSourceMaps:true, sourceRoot set — map's sourceRoot field is written; sources[] is
1641
     * relative to sourceRoot so that path.resolve(sourceRoot, sources[0]) gives the source file.
1642
     */
1643
    private serializeSourceMap(sourceMap: SourceMapGenerator, outputPath: string): string {
1644
        const { relativeSourceMaps, sourceRoot } = this.options;
29✔
1645

1646
        if (!relativeSourceMaps) {
29✔
1647
            return sourceMap.toString();
21✔
1648
        }
1649

1650
        const mapJson = sourceMap.toJSON();
8✔
1651

1652
        if (sourceRoot) {
8✔
1653
            mapJson.sourceRoot = sourceRoot;
4✔
1654
            mapJson.sources = mapJson.sources.map((source: string) => {
4✔
1655
                const absolute = path.isAbsolute(source) ? source : path.resolve(path.dirname(outputPath), source);
4!
1656
                return path.relative(sourceRoot, absolute).replace(/\\/g, '/');
4✔
1657
            });
1658
        } else {
1659
            const mapDir = path.dirname(outputPath);
4✔
1660
            mapJson.sources = mapJson.sources.map((source: string) => {
4✔
1661
                return path.isAbsolute(source)
4✔
1662
                    ? path.relative(mapDir, source).replace(/\\/g, '/')
4!
1663
                    : source;
1664
            });
1665
        }
1666

1667
        return JSON.stringify(mapJson);
8✔
1668
    }
1669

1670
    /**
1671
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1672
     */
1673
    public findFilesForFunction(functionName: string) {
1674
        const files = [] as BscFile[];
47✔
1675
        const lowerFunctionName = functionName.toLowerCase();
47✔
1676
        //find every file with this function defined
1677
        for (const file of Object.values(this.files)) {
47✔
1678
            if (isBrsFile(file)) {
137✔
1679
                //TODO handle namespace-relative function calls
1680
                //if the file has a function with this name
1681
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
102✔
1682
                    files.push(file);
24✔
1683
                }
1684
            }
1685
        }
1686
        return files;
47✔
1687
    }
1688

1689
    /**
1690
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1691
     */
1692
    public findFilesForClass(className: string) {
1693
        const files = [] as BscFile[];
47✔
1694
        const lowerClassName = className.toLowerCase();
47✔
1695
        //find every file with this class defined
1696
        for (const file of Object.values(this.files)) {
47✔
1697
            if (isBrsFile(file)) {
137✔
1698
                //TODO handle namespace-relative classes
1699
                //if the file has a function with this name
1700
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
102✔
1701
                    files.push(file);
3✔
1702
                }
1703
            }
1704
        }
1705
        return files;
47✔
1706
    }
1707

1708
    public findFilesForNamespace(name: string) {
1709
        const files = [] as BscFile[];
47✔
1710
        const lowerName = name.toLowerCase();
47✔
1711
        //find every file with this class defined
1712
        for (const file of Object.values(this.files)) {
47✔
1713
            if (isBrsFile(file)) {
137✔
1714
                if (file.parser.references.namespaceStatements.find((x) => {
102✔
1715
                    const namespaceName = x.name.toLowerCase();
14✔
1716
                    return (
14✔
1717
                        //the namespace name matches exactly
1718
                        namespaceName === lowerName ||
18✔
1719
                        //the full namespace starts with the name (honoring the part boundary)
1720
                        namespaceName.startsWith(lowerName + '.')
1721
                    );
1722
                })) {
1723
                    files.push(file);
12✔
1724
                }
1725
            }
1726
        }
1727
        return files;
47✔
1728
    }
1729

1730
    public findFilesForEnum(name: string) {
1731
        const files = [] as BscFile[];
48✔
1732
        const lowerName = name.toLowerCase();
48✔
1733
        //find every file with this class defined
1734
        for (const file of Object.values(this.files)) {
48✔
1735
            if (isBrsFile(file)) {
138✔
1736
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
103✔
1737
                    files.push(file);
1✔
1738
                }
1739
            }
1740
        }
1741
        return files;
48✔
1742
    }
1743

1744
    private _manifest: Map<string, string>;
1745
    private _manifestEntries: ManifestEntry[];
1746

1747
    /**
1748
     * The absolute source path to the manifest file. Set when loadManifest is called.
1749
     */
1750
    public manifestPath: string;
1751

1752
    /**
1753
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1754
     * @param parsedManifest The manifest map to read from and modify
1755
     */
1756
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1757
        // Lift the bs_consts defined in the manifest
1758
        let bsConsts = getBsConst(parsedManifest, false);
53✔
1759

1760
        // Override or delete any bs_consts defined in the bs config
1761
        for (const key in this.options?.manifest?.bs_const) {
53!
1762
            const value = this.options.manifest.bs_const[key];
3✔
1763
            if (value === null) {
3✔
1764
                bsConsts.delete(key);
1✔
1765
            } else {
1766
                bsConsts.set(key, value);
2✔
1767
            }
1768
        }
1769

1770
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1771
        let constString = '';
53✔
1772
        for (const [key, value] of bsConsts) {
53✔
1773
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1774
        }
1775

1776
        // Set the updated bs_const value
1777
        parsedManifest.set('bs_const', constString);
53✔
1778
    }
1779

1780
    /**
1781
     * Try to find and load the manifest into memory
1782
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1783
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1784
     */
1785
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,347✔
1786
        //if we already have a manifest instance, and should not replace...then don't replace
1787
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,362!
1788
            return;
×
1789
        }
1790
        let manifestPath = manifestFileObj
1,362✔
1791
            ? manifestFileObj.src
1,362✔
1792
            : path.join(this.options.rootDir, 'manifest');
1793

1794
        //store the resolved manifest path so it can be used externally for change detection
1795
        this.manifestPath = util.standardizePath(manifestPath);
1,362✔
1796

1797
        try {
1,362✔
1798
            // we only load this manifest once, so do it sync to improve speed downstream
1799
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,362✔
1800
            const parsedManifest = parseManifest(contents);
53✔
1801
            this.buildBsConstsIntoParsedManifest(parsedManifest);
53✔
1802
            this._manifest = parsedManifest;
53✔
1803
            this._manifestEntries = parseManifestEntries(contents);
53✔
1804
            this._manifestPath = manifestPath;
53✔
1805
        } catch (e) {
1806
            this._manifest = new Map();
1,309✔
1807
            this._manifestEntries = [];
1,309✔
1808
            this._manifestPath = undefined;
1,309✔
1809
        }
1810
    }
1811

1812
    /**
1813
     * Get a map of the manifest information
1814
     */
1815
    public getManifest() {
1816
        if (!this._manifest) {
1,597✔
1817
            this.loadManifest();
1,199✔
1818
        }
1819
        return this._manifest;
1,597✔
1820
    }
1821

1822
    /**
1823
     * Get the manifest as a list of `{ key, value, range }` entries with line/column ranges
1824
     * suitable for attaching diagnostics to specific manifest lines.
1825
     *
1826
     * NOTE: protected for now. The shape of this data is likely to evolve as we build out
1827
     * more manifest-aware features (validation rules, autocomplete, etc.). External plugins
1828
     * shouldn't depend on this until we commit to a stable API.
1829
     */
1830
    protected getManifestEntries(): ManifestEntry[] {
1831
        if (!this._manifestEntries) {
1,119✔
1832
            this.loadManifest();
147✔
1833
        }
1834
        return this._manifestEntries;
1,119✔
1835
    }
1836

1837
    private _manifestPath: string | undefined;
1838

1839
    /**
1840
     * Returns the absolute path of the loaded manifest file, or undefined if no manifest was found.
1841
     *
1842
     * NOTE: protected for now. Once brighterscript treats the manifest as a proper file
1843
     * (with editor / BscFile integration) callers should consume that instead of poking at
1844
     * the Program. External plugins shouldn't depend on this in the meantime.
1845
     */
1846
    protected getManifestPath(): string | undefined {
1847
        if (!this._manifest) {
1,117!
NEW
1848
            this.loadManifest();
×
1849
        }
1850
        return this._manifestPath;
1,117✔
1851
    }
1852

1853
    private _minFirmwareVersion: string | undefined;
1854

1855
    /**
1856
     * The minimum Roku firmware version brighterscript should assume the user is targeting.
1857
     * If `options.minFirmwareVersion` is set AND parseable as semver, the canonical coerced
1858
     * form ("15.0" → "15.0.0") wins; otherwise (unset or unparseable) falls back to
1859
     * {@link DEFAULT_MIN_FIRMWARE_VERSION}. The return is always a valid semver string so
1860
     * downstream callers can pass it directly to `semver.gte`/`semver.lt` without re-coercing.
1861
     * Cached after first call.
1862
     */
1863
    public getMinFirmwareVersion(): string {
1864
        if (this._minFirmwareVersion === undefined) {
70✔
1865
            const userValue = this.options.minFirmwareVersion;
48✔
1866
            const coerced = userValue ? semver.coerce(userValue) : undefined;
48✔
1867
            this._minFirmwareVersion = coerced ? coerced.version : DEFAULT_MIN_FIRMWARE_VERSION;
48✔
1868
        }
1869
        return this._minFirmwareVersion;
70✔
1870
    }
1871

1872
    private _rsgVersion: string | undefined;
1873

1874
    /**
1875
     * Returns the effective `rsg_version` for this program in canonical semver form (e.g. "1.2"
1876
     * → "1.2.0"). If the manifest declares a value explicitly and it's parseable, the canonical
1877
     * form wins; otherwise (manifest silent OR value is malformed) we fall back to the highest
1878
     * known rsg_version whose `becameDefaultAt` is `<=` the effective minimum firmware version,
1879
     * driven by {@link RSG_VERSIONS}. Cached after first call.
1880
     *
1881
     * Manifest validation (format errors, etc.) happens in `ProgramValidator` against the raw
1882
     * entry — that path doesn't go through this method.
1883
     */
1884
    public getRsgVersion(): string {
1885
        if (this._rsgVersion !== undefined) {
16!
NEW
1886
            return this._rsgVersion;
×
1887
        }
1888
        const explicit = this.getManifest().get('rsg_version');
16✔
1889
        const explicitCoerced = explicit !== undefined ? semver.coerce(explicit.trim()) : undefined;
16✔
1890
        if (explicitCoerced) {
16✔
1891
            this._rsgVersion = explicitCoerced.version;
5✔
1892
            return this._rsgVersion;
5✔
1893
        }
1894
        //walk known rsg_versions in descending order (newest first) and return the first whose
1895
        //becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0'
1896
        //(currently `1.1`), this loop always finds a match.
1897
        const minFirmwareVersion = this.getMinFirmwareVersion();
11✔
1898
        const candidates = Object.entries(RSG_VERSIONS)
11✔
1899
            .filter(([, info]) => info.becameDefaultAt !== undefined)
44✔
1900
            .sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!));
33✔
1901
        for (const [version, info] of candidates) {
11✔
1902
            if (semver.gte(minFirmwareVersion, info.becameDefaultAt!)) {
25✔
1903
                this._rsgVersion = semver.coerce(version)!.version;
11✔
1904
                return this._rsgVersion;
11✔
1905
            }
1906
        }
1907
        //unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0'
NEW
1908
        this._rsgVersion = DEFAULT_MIN_FIRMWARE_VERSION;
×
NEW
1909
        return this._rsgVersion;
×
1910
    }
1911

1912
    public dispose() {
1913
        this.plugins.emit('beforeProgramDispose', { program: this });
1,548✔
1914

1915
        for (let filePath in this.files) {
1,548✔
1916
            this.files[filePath].dispose();
1,523✔
1917
        }
1918
        for (let name in this.scopes) {
1,548✔
1919
            this.scopes[name].dispose();
3,037✔
1920
        }
1921
        this.globalScope.dispose();
1,548✔
1922
        this.dependencyGraph.dispose();
1,548✔
1923
    }
1924
}
1925

1926
export interface FileTranspileResult {
1927
    srcPath: string;
1928
    pkgPath: string;
1929
    code: string;
1930
    map: SourceMapGenerator;
1931
    typedef: string;
1932
}
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