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

rokucommunity / brighterscript / #15516

30 Mar 2026 07:54PM UTC coverage: 88.887% (-0.1%) from 89.035%
#15516

push

web-flow
Merge 7aa4b1e2f into f3673e7df

8260 of 9795 branches covered (84.33%)

Branch coverage included in aggregate %.

141 of 165 new or added lines in 5 files covered. (85.45%)

2 existing lines in 2 files now uncovered.

10448 of 11252 relevant lines covered (92.85%)

2000.11 hits per line

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

90.67
/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, WorkspaceEdit } from 'vscode-languageserver';
5
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
1✔
6
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
7
import { Scope } from './Scope';
1✔
8
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
9
import { BrsFile } from './files/BrsFile';
1✔
10
import { XmlFile } from './files/XmlFile';
1✔
11
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent } from './interfaces';
12
import { standardizePath as s, util } from './util';
1✔
13
import { XmlScope } from './XmlScope';
1✔
14
import { DiagnosticFilterer } from './DiagnosticFilterer';
1✔
15
import { DependencyGraph } from './DependencyGraph';
1✔
16
import 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 { SourceNode } from 'source-map';
28
import type { SourceMapGenerator } from 'source-map';
29
import { BrsTranspileState } from './parser/BrsTranspileState';
1✔
30
import type { Statement } from './parser/AstNode';
31
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
32
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
33
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
1✔
34
import { Sequencer } from './common/Sequencer';
1✔
35
import { Deferred } from './deferred';
1✔
36

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

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

51
export interface TranspileObj {
52
    file: BscFile;
53
    outputPath: string;
54
}
55

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

62
/**
63
 * Context for a single CDATA block. Returned by `Program.resolveCdataContext` when a
64
 * position falls inside an inline script block. Because synthetic BrsFiles are created
65
 * with offset-padded content, their positions are already in parent XML coordinate space
66
 * — no coordinate transformation is required.
67
 */
68
export interface CdataContext {
69
    brsFile: BrsFile;
70
    xmlFile: XmlFile;
71
    cdataRange: Range;
72
}
73

74
export class Program {
1✔
75
    constructor(
76
        /**
77
         * The root directory for this program
78
         */
79
        options: BsConfig,
80
        logger?: Logger,
81
        plugins?: PluginInterface
82
    ) {
83
        this.options = util.normalizeConfig(options);
1,522✔
84
        this.logger = logger ?? createLogger(options);
1,522✔
85
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,522✔
86

87
        //inject the bsc plugin as the first plugin in the stack.
88
        this.plugins.addFirst(new BscPlugin());
1,522✔
89

90
        //normalize the root dir path
91
        this.options.rootDir = util.getRootDir(this.options);
1,522✔
92

93
        this.createGlobalScope();
1,522✔
94
    }
95

96
    public options: FinalizedBsConfig;
97
    public logger: Logger;
98

99
    private createGlobalScope() {
100
        //create the 'global' scope
101
        this.globalScope = new Scope('global', this, 'scope:global');
1,522✔
102
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,522✔
103
        this.scopes.global = this.globalScope;
1,522✔
104
        //hardcode the files list for global scope to only contain the global file
105
        this.globalScope.getAllFiles = () => [globalFile];
23,288✔
106
        this.globalScope.validate();
1,522✔
107
        //for now, disable validation of global scope because the global files have some duplicate method declarations
108
        this.globalScope.getDiagnostics = () => [];
1,522✔
109
        //TODO we might need to fix this because the isValidated clears stuff now
110
        (this.globalScope as any).isValidated = true;
1,522✔
111
    }
112

113
    /**
114
     * A graph of all files and their dependencies.
115
     * For example:
116
     *      File.xml -> [lib1.brs, lib2.brs]
117
     *      lib2.brs -> [lib3.brs] //via an import statement
118
     */
119
    private dependencyGraph = new DependencyGraph();
1,522✔
120

121
    private diagnosticFilterer = new DiagnosticFilterer();
1,522✔
122

123
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,522✔
124

125
    /**
126
     * A scope that contains all built-in global functions.
127
     * All scopes should directly or indirectly inherit from this scope
128
     */
129
    public globalScope: Scope = undefined as any;
1,522✔
130

131
    /**
132
     * Plugins which can provide extra diagnostics or transform AST
133
     */
134
    public plugins: PluginInterface;
135

136
    /**
137
     * A set of diagnostics. This does not include any of the scope diagnostics.
138
     * Should only be set from `this.validate()`
139
     */
140
    private diagnostics = [] as BsDiagnostic[];
1,522✔
141

142
    /**
143
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
144
     */
145
    public get bslibPkgPath() {
146
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
147
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
472✔
148
            return bslibAliasedRokuModulesPkgPath;
2✔
149

150
            //if there's a non-aliased version of bslib from roku_modules, use that
151
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
470✔
152
            return bslibNonAliasedRokuModulesPkgPath;
3✔
153

154
            //default to the embedded version
155
        } else {
156
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
467✔
157
        }
158
    }
159

160
    public get bslibPrefix() {
161
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
435✔
162
            return 'rokucommunity_bslib';
3✔
163
        } else {
164
            return 'bslib';
432✔
165
        }
166
    }
167

168

169
    /**
170
     * A map of every file loaded into this program, indexed by its original file location
171
     */
172
    public files = {} as Record<string, BscFile>;
1,522✔
173
    private pkgMap = {} as Record<string, BscFile>;
1,522✔
174

175
    /**
176
     * Metadata for synthetic BrsFiles extracted from CDATA blocks.
177
     * Keyed by the synthetic file's pkgPath (lowercase).
178
     * Used to remap diagnostics back to the correct position in the parent XML file.
179
     */
180
    private syntheticFileMeta = new Map<string, { xmlFile: XmlFile; cdataRange: Range }>();
1,522✔
181

182
    /**
183
     * When set, `getDiagnostics()` will skip remapping diagnostics for this synthetic BrsFile,
184
     * leaving them in synthetic-file coordinate space. Used during code action events for CDATA
185
     * blocks so that plugins can match diagnostics by file identity (`x.file === event.file`).
186
     */
187
    private _cdataDiagnosticsContext: BrsFile | undefined;
188

189
    private scopes = {} as Record<string, Scope>;
1,522✔
190

191
    protected addScope(scope: Scope) {
192
        this.scopes[scope.name] = scope;
1,399✔
193
    }
194

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

203
    /**
204
     * Get the component with the specified name
205
     */
206
    public getComponent(componentName: string) {
207
        if (componentName) {
704✔
208
            //return the first compoment in the list with this name
209
            //(components are ordered in this list by pkgPath to ensure consistency)
210
            return this.components[componentName.toLowerCase()]?.[0];
661✔
211
        } else {
212
            return undefined;
43✔
213
        }
214
    }
215

216
    /**
217
     * Register (or replace) the reference to a component in the component map
218
     */
219
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
220
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
291✔
221
        if (!this.components[key]) {
291✔
222
            this.components[key] = [];
277✔
223
        }
224
        this.components[key].push({
291✔
225
            file: xmlFile,
226
            scope: scope
227
        });
228
        this.components[key].sort((a, b) => {
291✔
229
            const pathA = a.file.pkgPath.toLowerCase();
5✔
230
            const pathB = b.file.pkgPath.toLowerCase();
5✔
231
            if (pathA < pathB) {
5✔
232
                return -1;
1✔
233
            } else if (pathA > pathB) {
4!
234
                return 1;
4✔
235
            }
236
            return 0;
×
237
        });
238
        this.syncComponentDependencyGraph(this.components[key]);
291✔
239
    }
240

241
    /**
242
     * Remove the specified component from the components map
243
     */
244
    private unregisterComponent(xmlFile: XmlFile) {
245
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
14✔
246
        const arr = this.components[key] || [];
14!
247
        for (let i = 0; i < arr.length; i++) {
14✔
248
            if (arr[i].file === xmlFile) {
14!
249
                arr.splice(i, 1);
14✔
250
                break;
14✔
251
            }
252
        }
253
        this.syncComponentDependencyGraph(arr);
14✔
254
    }
255

256
    /**
257
     * re-attach the dependency graph with a new key for any component who changed
258
     * their position in their own named array (only matters when there are multiple
259
     * components with the same name)
260
     */
261
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
262
        //reattach every dependency graph
263
        for (let i = 0; i < components.length; i++) {
305✔
264
            const { file, scope } = components[i];
297✔
265

266
            //attach (or re-attach) the dependencyGraph for every component whose position changed
267
            if (file.dependencyGraphIndex !== i) {
297✔
268
                file.dependencyGraphIndex = i;
293✔
269
                file.attachDependencyGraph(this.dependencyGraph);
293✔
270
                scope.attachDependencyGraph(this.dependencyGraph);
293✔
271
            }
272
        }
273
    }
274

275
    /**
276
     * Get a list of all files that are included in the project but are not referenced
277
     * by any scope in the program.
278
     */
279
    public getUnreferencedFiles() {
280
        let result = [] as File[];
1,017✔
281
        for (let filePath in this.files) {
1,017✔
282
            let file = this.files[filePath];
1,268✔
283
            //is this file part of a scope
284
            if (!this.getFirstScopeForFile(file)) {
1,268✔
285
                //no scopes reference this file. add it to the list
286
                result.push(file);
45✔
287
            }
288
        }
289
        return result;
1,017✔
290
    }
291

292
    /**
293
     * Get the list of errors for the entire program. It's calculated on the fly
294
     * by walking through every file, so call this sparingly.
295
     */
296
    public getDiagnostics() {
297
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
1,017✔
298

299
            let diagnostics = [...this.diagnostics];
1,017✔
300

301
            //get the diagnostics from all scopes
302
            for (let scopeName in this.scopes) {
1,017✔
303
                let scope = this.scopes[scopeName];
2,070✔
304
                diagnostics.push(
2,070✔
305
                    ...scope.getDiagnostics()
306
                );
307
            }
308

309
            //get the diagnostics from all unreferenced files
310
            let unreferencedFiles = this.getUnreferencedFiles();
1,017✔
311
            for (let file of unreferencedFiles) {
1,017✔
312
                diagnostics.push(
45✔
313
                    ...file.getDiagnostics()
314
                );
315
            }
316
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
1,017✔
317
                //filter out diagnostics based on our diagnostic filters
318
                let finalDiagnostics = this.diagnosticFilterer.filter({
1,017✔
319
                    ...this.options,
320
                    rootDir: this.options.rootDir
321
                }, diagnostics);
322
                return finalDiagnostics;
1,017✔
323
            });
324

325
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,017✔
326
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,017✔
327
            });
328

329
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
1,017✔
330
            return this._cdataDiagnosticsContext
1,017✔
331
                ? this.partialRemapSyntheticFileDiagnostics(filteredDiagnostics, this._cdataDiagnosticsContext)
1,017✔
332
                : this.remapSyntheticFileDiagnostics(filteredDiagnostics);
333
        });
334
    }
335

336
    /**
337
     * Redirects diagnostics from synthetic CDATA BrsFiles to their parent XmlFile.
338
     * Because synthetic files are created with offset-padded content, ranges are already
339
     * in XML coordinate space — only the file reference needs updating.
340
     */
341
    private remapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[]): BsDiagnostic[] {
342
        return diagnostics.map(diagnostic => {
1,016✔
343
            if (!diagnostic.file?.isSynthetic) {
542!
344
                return diagnostic;
541✔
345
            }
346
            const meta = this.syntheticFileMeta.get(diagnostic.file.pkgPath.toLowerCase());
1✔
347
            if (!meta) {
1!
NEW
348
                return diagnostic;
×
349
            }
350
            return { ...diagnostic, file: meta.xmlFile };
1✔
351
        });
352
    }
353

354
    /**
355
     * Like `remapSyntheticFileDiagnostics`, but keeps `contextFile`'s diagnostics pointing
356
     * at the synthetic BrsFile so plugins can match by file identity during code action events.
357
     */
358
    private partialRemapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[], contextFile: BrsFile): BsDiagnostic[] {
359
        return diagnostics.map(diagnostic => {
1✔
NEW
360
            if (!diagnostic.file?.isSynthetic) {
×
NEW
361
                return diagnostic;
×
362
            }
NEW
363
            if (diagnostic.file === contextFile) {
×
NEW
364
                return diagnostic;
×
365
            }
NEW
366
            const meta = this.syntheticFileMeta.get(diagnostic.file.pkgPath.toLowerCase());
×
NEW
367
            if (!meta) {
×
NEW
368
                return diagnostic;
×
369
            }
NEW
370
            return { ...diagnostic, file: meta.xmlFile };
×
371
        });
372
    }
373

374
    /**
375
     * Returns the CDATA metadata and corresponding synthetic BrsFile for the CDATA block whose
376
     * range intersects `range` within `xmlFile`, or `undefined` if no block matches.
377
     */
378
    private findCdataInfoForRange(xmlFile: XmlFile, range: Range): { meta: { xmlFile: XmlFile; cdataRange: Range }; brsFile: BrsFile } | undefined {
379
        for (const [pkgPathKey, meta] of this.syntheticFileMeta) {
27✔
380
            if (meta.xmlFile !== xmlFile) {
10✔
381
                continue;
1✔
382
            }
383
            if (util.rangesIntersectOrTouch(meta.cdataRange, range)) {
9✔
384
                const brsFile = this.getFile<BrsFile>(pkgPathKey);
8✔
385
                if (brsFile) {
8!
386
                    return { meta: meta, brsFile: brsFile };
8✔
387
                }
388
            }
389
        }
390
        return undefined;
19✔
391
    }
392

393
    /**
394
     * After code actions are generated using a synthetic BrsFile as the target, this substitutes
395
     * the synthetic file URI with the parent XML file URI in any workspace edit changes.
396
     * Ranges are already in XML coordinate space and need no adjustment.
397
     */
398
    private remapCodeActionChangesToXml(codeActions: CodeAction[], brsFile: BrsFile, xmlFile: XmlFile) {
399
        const syntheticUri = URI.file(brsFile.srcPath).toString();
1✔
400
        const xmlUri = URI.file(xmlFile.srcPath).toString();
1✔
401
        for (const action of codeActions) {
1✔
NEW
402
            const changes = (action.edit as WorkspaceEdit)?.changes;
×
NEW
403
            if (!changes?.[syntheticUri]) {
×
NEW
404
                continue;
×
405
            }
NEW
406
            const syntheticEdits = changes[syntheticUri];
×
NEW
407
            delete changes[syntheticUri];
×
NEW
408
            if (!changes[xmlUri]) {
×
NEW
409
                changes[xmlUri] = [];
×
410
            }
NEW
411
            changes[xmlUri].push(...syntheticEdits);
×
412
        }
413
    }
414

415
    /**
416
     * Given an XmlFile and a cursor position, returns a `CdataContext` if the position falls
417
     * inside a `<![CDATA[...]]>` block, or `undefined` if it does not.
418
     * Because synthetic files use offset-padded content, no position transformation is needed —
419
     * pass the original XML-space position directly to the synthetic BrsFile's handlers.
420
     */
421
    public resolveCdataContext(xmlFile: XmlFile, position: Position): CdataContext | undefined {
422
        const pointRange = util.createRange(position.line, position.character, position.line, position.character);
27✔
423
        const info = this.findCdataInfoForRange(xmlFile, pointRange);
27✔
424
        if (!info) {
27✔
425
            return undefined;
19✔
426
        }
427
        return {
8✔
428
            brsFile: info.brsFile,
429
            xmlFile: xmlFile,
430
            cdataRange: info.meta.cdataRange
431
        };
432
    }
433

434
    /**
435
     * Substitutes the synthetic BrsFile URI with the parent XML file URI in any Location
436
     * entries that reference it. Ranges are already in XML coordinate space.
437
     */
438
    private remapLocationsFromSynthetic(locations: Location[], cdataCtx: CdataContext): Location[] {
439
        const syntheticUri = URI.file(cdataCtx.brsFile.srcPath).toString();
2✔
440
        const xmlUri = URI.file(cdataCtx.xmlFile.srcPath).toString();
2✔
441
        return locations.map(loc => (loc.uri === syntheticUri ? { uri: xmlUri, range: loc.range } : loc));
2!
442
    }
443

444
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
445
        this.diagnostics.push(...diagnostics);
45✔
446
    }
447

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

457
    public getPkgPath(...args: any[]): any { //eslint-disable-line
458
        throw new Error('Not implemented');
×
459
    }
460

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

475
    /**
476
     * Return all scopes
477
     */
478
    public getScopes() {
479
        return Object.values(this.scopes);
9✔
480
    }
481

482
    /**
483
     * Find the scope for the specified component
484
     */
485
    public getComponentScope(componentName: string) {
486
        return this.getComponent(componentName)?.scope;
209✔
487
    }
488

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

498
    /**
499
     * Remove this file from internal maps
500
     */
501
    private unassignFile<T extends BscFile = BscFile>(file: T) {
502
        delete this.files[file.srcPath.toLowerCase()];
192✔
503
        delete this.pkgMap[file.pkgPath.toLowerCase()];
192✔
504
        return file;
192✔
505
    }
506

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

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

543
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,607✔
544
            //if the file is already loaded, remove it
545
            if (this.hasFile(srcPath)) {
1,607✔
546
                this.removeFile(srcPath);
139✔
547
            }
548
            let fileExtension = path.extname(srcPath).toLowerCase();
1,607✔
549
            let file: BscFile | undefined;
550

551
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,607✔
552
                //add the file to the program
553
                const brsFile = this.assignFile(
1,312✔
554
                    new BrsFile(srcPath, pkgPath, this)
555
                );
556

557
                //add file to the `source` dependency list
558
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,312✔
559
                    this.createSourceScope();
1,086✔
560
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,086✔
561
                }
562

563
                let sourceObj: SourceObj = {
1,312✔
564
                    //TODO remove `pathAbsolute` in v1
565
                    pathAbsolute: srcPath,
566
                    srcPath: srcPath,
567
                    source: fileContents
568
                };
569
                this.plugins.emit('beforeFileParse', sourceObj);
1,312✔
570

571
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,312✔
572
                    brsFile.parse(sourceObj.source);
1,312✔
573
                });
574

575
                //notify plugins that this file has finished parsing
576
                this.plugins.emit('afterFileParse', brsFile);
1,312✔
577

578
                file = brsFile;
1,312✔
579

580
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,312✔
581

582
            } else if (
295✔
583
                //is xml file
584
                fileExtension === '.xml' &&
589✔
585
                //resides in the components folder (Roku will only parse xml files in the components folder)
586
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
587
            ) {
588
                //add the file to the program
589
                const xmlFile = this.assignFile(
291✔
590
                    new XmlFile(srcPath, pkgPath, this)
591
                );
592

593
                let sourceObj: SourceObj = {
291✔
594
                    //TODO remove `pathAbsolute` in v1
595
                    pathAbsolute: srcPath,
596
                    srcPath: srcPath,
597
                    source: fileContents
598
                };
599
                this.plugins.emit('beforeFileParse', sourceObj);
291✔
600

601
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
291✔
602
                    xmlFile.parse(sourceObj.source);
291✔
603
                });
604

605
                //notify plugins that this file has finished parsing
606
                this.plugins.emit('afterFileParse', xmlFile);
291✔
607

608
                file = xmlFile;
291✔
609

610
                //register synthetic BrsFiles for any inline CDATA script blocks.
611
                //these are treated as first-class files so all plugins (linters, validators, etc.) see them normally.
612
                let cdataScriptIndex = 0;
291✔
613
                for (const script of xmlFile.ast.component?.scripts ?? []) {
291✔
614
                    if (script.cdata) {
220✔
615
                        const inlinePkgPath = xmlFile.inlineScriptPkgPaths[cdataScriptIndex++];
37✔
616
                        // Pad the content with leading newlines and spaces so that every
617
                        // position in the synthetic file naturally aligns with its position in
618
                        // the parent XML file. This eliminates all coordinate remapping for LSP
619
                        // events — only file URI substitution is needed in results.
620
                        const cdataRange = script.cdata.range;
37✔
621
                        const contentStartChar = cdataRange.start.character + '<![CDATA['.length;
37✔
622
                        const paddedContent = '\n'.repeat(cdataRange.start.line) +
37✔
623
                            ' '.repeat(contentStartChar) +
624
                            (script.cdataText ?? '');
111!
625
                        const inlineFile = this.setFile<BrsFile>(inlinePkgPath, paddedContent);
37✔
626
                        inlineFile.isSynthetic = true;
37✔
627
                        this.syntheticFileMeta.set(s`${inlinePkgPath}`.toLowerCase(), {
37✔
628
                            xmlFile: xmlFile,
629
                            cdataRange: cdataRange
630
                        });
631
                    }
632
                }
633

634
                //create a new scope for this xml file
635
                let scope = new XmlScope(xmlFile, this);
291✔
636
                this.addScope(scope);
291✔
637

638
                //register this compoent now that we have parsed it and know its component name
639
                this.registerComponent(xmlFile, scope);
291✔
640

641
                //notify plugins that the scope is created and the component is registered
642
                this.plugins.emit('afterScopeCreate', scope);
291✔
643
            } else {
644
                //TODO do we actually need to implement this? Figure out how to handle img paths
645
                // let genericFile = this.files[srcPath] = <any>{
646
                //     srcPath: srcPath,
647
                //     pkgPath: pkgPath,
648
                //     wasProcessed: true
649
                // } as File;
650
                // file = <any>genericFile;
651
            }
652
            return file;
1,607✔
653
        });
654
        return file as T;
1,607✔
655
    }
656

657
    /**
658
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
659
     * @param fileParam an object representing file paths
660
     * @param rootDir must be a pre-normalized path
661
     */
662
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
663
        let srcPath: string | undefined;
664
        let pkgPath: string | undefined;
665

666
        assert.ok(fileParam, 'fileParam is required');
1,614✔
667

668
        //lift the srcPath and pkgPath vars from the incoming param
669
        if (typeof fileParam === 'string') {
1,614✔
670
            fileParam = this.removePkgPrefix(fileParam);
1,152✔
671
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,152✔
672
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,152✔
673
        } else {
674
            let param: any = fileParam;
462✔
675

676
            if (param.src) {
462✔
677
                srcPath = s`${param.src}`;
459✔
678
            }
679
            if (param.srcPath) {
462✔
680
                srcPath = s`${param.srcPath}`;
2✔
681
            }
682
            if (param.dest) {
462✔
683
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
459✔
684
            }
685
            if (param.pkgPath) {
462✔
686
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
687
            }
688
        }
689

690
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
691
        if (!srcPath) {
1,614✔
692
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
693
        }
694
        //coerce srcPath to an absolute path
695
        if (!path.isAbsolute(srcPath)) {
1,614✔
696
            srcPath = util.standardizePath(srcPath);
1✔
697
        }
698

699
        //if there's no pkgPath, compute relative path from rootDir
700
        if (!pkgPath) {
1,614✔
701
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
702
        }
703

704
        assert.ok(srcPath, 'fileEntry.src is required');
1,614✔
705
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,614✔
706

707
        return {
1,614✔
708
            srcPath: srcPath,
709
            //remove leading slash from pkgPath
710
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
711
        };
712
    }
713

714
    /**
715
     * Remove any leading `pkg:/` found in the path
716
     */
717
    private removePkgPrefix(path: string) {
718
        return path.replace(/^pkg:\//i, '');
1,613✔
719
    }
720

721
    /**
722
     * Ensure source scope is created.
723
     * Note: automatically called internally, and no-op if it exists already.
724
     */
725
    public createSourceScope() {
726
        if (!this.scopes.source) {
1,493✔
727
            const sourceScope = new Scope('source', this, 'scope:source');
1,108✔
728
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,108✔
729
            this.addScope(sourceScope);
1,108✔
730
            this.plugins.emit('afterScopeCreate', sourceScope);
1,108✔
731
        }
732
    }
733

734
    /**
735
     * Find the file by its absolute path. This is case INSENSITIVE, since
736
     * Roku is a case insensitive file system. It is an error to have multiple files
737
     * with the same path with only case being different.
738
     * @param srcPath the absolute path to the file
739
     * @deprecated use `getFile` instead, which auto-detects the path type
740
     */
741
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
742
        srcPath = s`${srcPath}`;
×
743
        for (let filePath in this.files) {
×
744
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
745
                return this.files[filePath] as T;
×
746
            }
747
        }
748
    }
749

750
    /**
751
     * Get a list of files for the given (platform-normalized) pkgPath array.
752
     * Missing files are just ignored.
753
     * @deprecated use `getFiles` instead, which auto-detects the path types
754
     */
755
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
756
        return pkgPaths
×
757
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
758
            .filter(file => file !== undefined) as T;
×
759
    }
760

761
    /**
762
     * Get a file with the specified (platform-normalized) pkg path.
763
     * If not found, return undefined
764
     * @deprecated use `getFile` instead, which auto-detects the path type
765
     */
766
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
767
        return this.pkgMap[pkgPath.toLowerCase()] as T;
516✔
768
    }
769

770
    /**
771
     * Remove a set of files from the program
772
     * @param srcPaths can be an array of srcPath or destPath strings
773
     * @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
774
     */
775
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
776
        for (let srcPath of srcPaths) {
1✔
777
            this.removeFile(srcPath, normalizePath);
1✔
778
        }
779
    }
780

781
    /**
782
     * Remove a file from the program
783
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
784
     * @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
785
     */
786
    public removeFile(filePath: string, normalizePath = true) {
188✔
787
        this.logger.debug('Program.removeFile()', filePath);
192✔
788

789
        let file = this.getFile(filePath, normalizePath);
192✔
790
        if (file) {
192!
791
            this.plugins.emit('beforeFileDispose', file);
192✔
792

793
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
794
            let scope = this.scopes[file.pkgPath];
192✔
795
            if (scope) {
192✔
796
                this.plugins.emit('beforeScopeDispose', scope);
14✔
797
                scope.dispose();
14✔
798
                //notify dependencies of this scope that it has been removed
799
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
14✔
800
                delete this.scopes[file.pkgPath];
14✔
801
                this.plugins.emit('afterScopeDispose', scope);
14✔
802
            }
803
            //remove the file from the program
804
            this.unassignFile(file);
192✔
805

806
            //clean up synthetic file metadata if this was a synthetic CDATA file
807
            if (isBrsFile(file) && file.isSynthetic) {
192✔
808
                this.syntheticFileMeta.delete(file.pkgPath.toLowerCase());
37✔
809
            }
810

811
            this.dependencyGraph.remove(file.dependencyGraphKey);
192✔
812

813
            //if this is a pkg:/source file, notify the `source` scope that it has changed
814
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
192✔
815
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
101✔
816
            }
817

818
            //if this is a component, remove it from our components map
819
            if (isXmlFile(file)) {
192✔
820
                this.unregisterComponent(file);
14✔
821
            }
822
            //dispose file
823
            file?.dispose();
192!
824
            this.plugins.emit('afterFileDispose', file);
192✔
825
        }
826
    }
827

828
    /**
829
     * Counter used to track which validation run is being logged
830
     */
831
    private validationRunSequence = 1;
1,522✔
832

833
    /**
834
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
835
     */
836
    private validationMinSyncDuration = 75;
1,522✔
837

838
    private validatePromise: Promise<void> | undefined;
839

840
    /**
841
     * Traverse the entire project, and validate all scopes
842
     */
843
    public validate(): void;
844
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
845
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
846
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
847
        const validationRunId = this.validationRunSequence++;
963✔
848
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
963!
849

850
        let previousValidationPromise = this.validatePromise;
963✔
851
        const deferred = new Deferred();
963✔
852

853
        if (options?.async) {
963✔
854
            //we're async, so create a new promise chain to resolve after this validation is done
855
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
856
                return deferred.promise;
159✔
857
            });
858

859
            //we are not async but there's a pending promise, then we cannot run this validation
860
        } else if (previousValidationPromise !== undefined) {
804!
861
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
862
        }
863

864
        if (options?.async) {
963✔
865
            //we're async, so create a new promise chain to resolve after this validation is done
866
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
867
                return deferred.promise;
159✔
868
            });
869

870
            //we are not async but there's a pending promise, then we cannot run this validation
871
        } else if (previousValidationPromise !== undefined) {
804!
872
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
873
        }
874

875
        const sequencer = new Sequencer({
963✔
876
            name: 'program.validate',
877
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,778✔
878
            minSyncDuration: this.validationMinSyncDuration
879
        });
880

881
        let beforeProgramValidateWasEmitted = false;
963✔
882

883
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
884
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
885
        sequencer
963✔
886
            .once(() => {
887
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
888
                if (options?.async) {
963✔
889
                    return previousValidationPromise;
159✔
890
                }
891
            })
892
            .once(() => {
893
                this.diagnostics = [];
961✔
894
                this.plugins.emit('beforeProgramValidate', this);
961✔
895
                beforeProgramValidateWasEmitted = true;
961✔
896
            })
897
            .forEach(() => Object.values(this.files), (file) => {
961✔
898
                if (!file.isValidated) {
1,255✔
899
                    this.plugins.emit('beforeFileValidate', {
1,188✔
900
                        program: this,
901
                        file: file
902
                    });
903

904
                    //emit an event to allow plugins to contribute to the file validation process
905
                    this.plugins.emit('onFileValidate', {
1,188✔
906
                        program: this,
907
                        file: file
908
                    });
909
                    //call file.validate() IF the file has that function defined
910
                    file.validate?.();
1,188!
911
                    file.isValidated = true;
1,187✔
912

913
                    this.plugins.emit('afterFileValidate', file);
1,187✔
914
                }
915
            })
916
            .forEach(Object.values(this.scopes), (scope) => {
917
                scope.linkSymbolTable();
1,992✔
918
                scope.validate();
1,992✔
919
                scope.unlinkSymbolTable();
1,990✔
920
            })
921
            .once(() => {
922
                this.detectDuplicateComponentNames();
953✔
923
            })
924
            .onCancel(() => {
925
                timeEnd('cancelled');
10✔
926
            })
927
            .onSuccess(() => {
928
                timeEnd();
953✔
929
            })
930
            .onComplete(() => {
931
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
932
                if (beforeProgramValidateWasEmitted) {
963✔
933
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
961✔
934
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
961✔
935
                }
936

937
                //regardless of the success of the validation, mark this run as complete
938
                deferred.resolve();
963✔
939
                //clear the validatePromise which means we're no longer running a validation
940
                this.validatePromise = undefined;
963✔
941
            });
942

943
        //run the sequencer in async mode if enabled
944
        if (options?.async) {
963✔
945
            return sequencer.run();
159✔
946

947
            //run the sequencer in sync mode
948
        } else {
949
            return sequencer.runSync();
804✔
950
        }
951
    }
952

953
    /**
954
     * Flag all duplicate component names
955
     */
956
    private detectDuplicateComponentNames() {
957
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
953✔
958
            const file = this.files[filePath];
1,246✔
959
            //if this is an XmlFile, and it has a valid `componentName` property
960
            if (isXmlFile(file) && file.componentName?.text) {
1,246✔
961
                let lowerName = file.componentName.text.toLowerCase();
255✔
962
                if (!map[lowerName]) {
255✔
963
                    map[lowerName] = [];
252✔
964
                }
965
                map[lowerName].push(file);
255✔
966
            }
967
            return map;
1,246✔
968
        }, {});
969

970
        for (let name in componentsByName) {
953✔
971
            const xmlFiles = componentsByName[name];
252✔
972
            //add diagnostics for every duplicate component with this name
973
            if (xmlFiles.length > 1) {
252✔
974
                for (let xmlFile of xmlFiles) {
3✔
975
                    const { componentName } = xmlFile;
6✔
976
                    this.diagnostics.push({
6✔
977
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
978
                        range: xmlFile.componentName.range,
979
                        file: xmlFile,
980
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
981
                            return {
6✔
982
                                location: util.createLocation(
983
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
984
                                    x.componentName.range
985
                                ),
986
                                message: 'Also defined here'
987
                            };
988
                        })
989
                    });
990
                }
991
            }
992
        }
993
    }
994

995
    /**
996
     * Get the files for a list of filePaths
997
     * @param filePaths can be an array of srcPath or a destPath strings
998
     * @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
999
     */
1000
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
34✔
1001
        return filePaths
34✔
1002
            .map(filePath => this.getFile(filePath, normalizePath))
42✔
1003
            .filter(file => file !== undefined) as T[];
42✔
1004
    }
1005

1006
    /**
1007
     * Get the file at the given path
1008
     * @param filePath can be a srcPath or a destPath
1009
     * @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
1010
     */
1011
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,533✔
1012
        if (typeof filePath !== 'string') {
8,408✔
1013
            return undefined;
1,739✔
1014
        } else if (path.isAbsolute(filePath)) {
6,669✔
1015
            return this.files[
3,468✔
1016
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,468✔
1017
            ] as T;
1018
        } else {
1019
            return this.pkgMap[
3,201✔
1020
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,201!
1021
            ] as T;
1022
        }
1023
    }
1024

1025
    /**
1026
     * Get a list of all scopes the file is loaded into
1027
     * @param file the file
1028
     */
1029
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1030

1031
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
605✔
1032

1033
        let result = [] as Scope[];
605✔
1034
        if (resolvedFile) {
605✔
1035
            for (let key in this.scopes) {
604✔
1036
                let scope = this.scopes[key];
1,261✔
1037

1038
                if (scope.hasFile(resolvedFile)) {
1,261✔
1039
                    result.push(scope);
615✔
1040
                }
1041
            }
1042
        }
1043
        return result;
605✔
1044
    }
1045

1046
    /**
1047
     * Get the first found scope for a file.
1048
     */
1049
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1050
        for (let key in this.scopes) {
2,802✔
1051
            let scope = this.scopes[key];
6,959✔
1052

1053
            if (scope.hasFile(file)) {
6,959✔
1054
                return scope;
2,661✔
1055
            }
1056
        }
1057
    }
1058

1059
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1060
        let results = new Map<Statement, FileLink<Statement>>();
40✔
1061
        const filesSearched = new Set<BrsFile>();
40✔
1062
        let lowerNamespaceName = namespaceName?.toLowerCase();
40✔
1063
        let lowerName = name?.toLowerCase();
40!
1064
        //look through all files in scope for matches
1065
        for (const scope of this.getScopesForFile(originFile)) {
40✔
1066
            for (const file of scope.getAllFiles()) {
40✔
1067
                if (isXmlFile(file) || filesSearched.has(file)) {
47✔
1068
                    continue;
4✔
1069
                }
1070
                filesSearched.add(file);
43✔
1071

1072
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
43✔
1073
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
100✔
1074
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
100✔
1075
                        if (!results.has(statement)) {
37!
1076
                            results.set(statement, { item: statement, file: file });
37✔
1077
                        }
1078
                    }
1079
                }
1080
            }
1081
        }
1082
        return [...results.values()];
40✔
1083
    }
1084

1085
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1086
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1087
        const filesSearched = new Set<BrsFile>();
8✔
1088

1089
        //get all function names for the xml file and parents
1090
        let funcNames = new Set<string>();
8✔
1091
        let currentScope = scope;
8✔
1092
        while (isXmlScope(currentScope)) {
8✔
1093
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1094
                if (!filterName || name === filterName) {
14!
1095
                    funcNames.add(name);
14✔
1096
                }
1097
            }
1098
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1099
        }
1100

1101
        //look through all files in scope for matches
1102
        for (const file of scope.getOwnFiles()) {
8✔
1103
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1104
                continue;
8✔
1105
            }
1106
            filesSearched.add(file);
8✔
1107

1108
            for (const statement of file.parser.references.functionStatements) {
8✔
1109
                if (funcNames.has(statement.name.text)) {
13!
1110
                    if (!results.has(statement)) {
13!
1111
                        results.set(statement, { item: statement, file: file });
13✔
1112
                    }
1113
                }
1114
            }
1115
        }
1116
        return [...results.values()];
8✔
1117
    }
1118

1119
    /**
1120
     * Find all available completion items at the given position
1121
     * @param filePath can be a srcPath or a destPath
1122
     * @param position the position (line & column) where completions should be found
1123
     */
1124
    public getCompletions(filePath: string, position: Position) {
1125
        let file = this.getFile(filePath);
79✔
1126
        if (!file) {
79!
1127
            return [];
×
1128
        }
1129

1130
        const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
79✔
1131

1132
        //find the scopes for this file
1133
        let scopes = this.getScopesForFile(effectiveFile);
79✔
1134

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

1138
        const event: ProvideCompletionsEvent = {
79✔
1139
            program: this,
1140
            file: effectiveFile,
1141
            scopes: scopes,
1142
            position: position,
1143
            completions: []
1144
        };
1145

1146
        this.plugins.emit('beforeProvideCompletions', event);
79✔
1147
        this.plugins.emit('provideCompletions', event);
79✔
1148
        this.plugins.emit('afterProvideCompletions', event);
79✔
1149

1150
        return event.completions;
79✔
1151
    }
1152

1153
    /**
1154
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1155
     */
1156
    public getWorkspaceSymbols() {
1157
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1158
            program: this,
1159
            workspaceSymbols: []
1160
        };
1161
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1162
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1163
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1164
        return event.workspaceSymbols;
22✔
1165
    }
1166

1167
    /**
1168
     * Given a position in a file, if the position is sitting on some type of identifier,
1169
     * go to the definition of that identifier (where this thing was first defined)
1170
     */
1171
    public getDefinition(srcPath: string, position: Position): Location[] {
1172
        let file = this.getFile(srcPath);
20✔
1173
        if (!file) {
20!
1174
            return [];
×
1175
        }
1176

1177
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
20✔
1178
        const effectiveFile = cdataCtx?.brsFile ?? file;
20✔
1179

1180
        const event: ProvideDefinitionEvent = {
20✔
1181
            program: this,
1182
            file: effectiveFile,
1183
            position: position,
1184
            definitions: []
1185
        };
1186

1187
        this.plugins.emit('beforeProvideDefinition', event);
20✔
1188
        this.plugins.emit('provideDefinition', event);
20✔
1189
        this.plugins.emit('afterProvideDefinition', event);
20✔
1190
        return cdataCtx ? this.remapLocationsFromSynthetic(event.definitions, cdataCtx) : event.definitions;
20✔
1191
    }
1192

1193
    /**
1194
     * Get hover information for a file and position
1195
     */
1196
    public getHover(srcPath: string, position: Position): Hover[] {
1197
        let file = this.getFile(srcPath);
33✔
1198
        let result: Hover[];
1199
        if (file) {
33!
1200
            const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
33!
1201

1202
            const event = {
33✔
1203
                program: this,
1204
                file: effectiveFile,
1205
                position: position,
1206
                scopes: this.getScopesForFile(effectiveFile),
1207
                hovers: []
1208
            } as ProvideHoverEvent;
1209
            this.plugins.emit('beforeProvideHover', event);
33✔
1210
            this.plugins.emit('provideHover', event);
33✔
1211
            this.plugins.emit('afterProvideHover', event);
33✔
1212

1213
            result = event.hovers;
33✔
1214
        }
1215

1216
        return result ?? [];
33!
1217
    }
1218

1219
    /**
1220
     * Get full list of document symbols for a file
1221
     * @param srcPath path to the file
1222
     */
1223
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1224
        let file = this.getFile(srcPath);
25✔
1225
        if (!file) {
25!
UNCOV
1226
            return undefined;
×
1227
        }
1228
        const event: ProvideDocumentSymbolsEvent = {
25✔
1229
            program: this,
1230
            file: file,
1231
            documentSymbols: []
1232
        };
1233
        this.plugins.emit('beforeProvideDocumentSymbols', event);
25✔
1234
        this.plugins.emit('provideDocumentSymbols', event);
25✔
1235
        this.plugins.emit('afterProvideDocumentSymbols', event);
25✔
1236

1237
        // For XML files, also collect symbols from each inline CDATA block.
1238
        // Ranges are already in XML coordinate space — no remapping needed.
1239
        if (isXmlFile(file)) {
25✔
1240
            for (const [pkgPath, meta] of this.syntheticFileMeta) {
2✔
1241
                if (meta.xmlFile !== file) {
1!
NEW
1242
                    continue;
×
1243
                }
1244
                const brsFile = this.getFile<BrsFile>(pkgPath);
1✔
1245
                if (!brsFile) {
1!
NEW
1246
                    continue;
×
1247
                }
1248
                const cdataEvent: ProvideDocumentSymbolsEvent = {
1✔
1249
                    program: this,
1250
                    file: brsFile,
1251
                    documentSymbols: []
1252
                };
1253
                this.plugins.emit('beforeProvideDocumentSymbols', cdataEvent);
1✔
1254
                this.plugins.emit('provideDocumentSymbols', cdataEvent);
1✔
1255
                this.plugins.emit('afterProvideDocumentSymbols', cdataEvent);
1✔
1256
                event.documentSymbols.push(...cdataEvent.documentSymbols);
1✔
1257
            }
1258
        }
1259

1260
        return event.documentSymbols;
25✔
1261
    }
1262

1263
    /**
1264
     * Compute code actions for the given file and range
1265
     */
1266
    public getCodeActions(srcPath: string, range: Range) {
1267
        const codeActions = [] as CodeAction[];
53✔
1268
        const file = this.getFile(srcPath);
53✔
1269
        if (file) {
53✔
1270
            // resolveCdataContext uses range.start as a probe point; findCdataInfoForRange
1271
            // is used internally to check intersection with the full range.
1272
            const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, range.start) : undefined;
52✔
1273

1274
            // When the range falls inside a CDATA block, redirect the event to the synthetic
1275
            // BrsFile so that BrsFile-specific code actions work correctly. Setting
1276
            // _cdataDiagnosticsContext causes getDiagnostics() to keep that file's diagnostics
1277
            // in synthetic-file coordinate space (rather than remapping to the parent XML file),
1278
            // so that plugins can match diagnostics by file identity (x.file === event.file)
1279
            // and derive correct edit positions from diagnostic.data (AST node ranges).
1280
            const effectiveFile = cdataCtx?.brsFile ?? file;
52✔
1281

1282
            this._cdataDiagnosticsContext = cdataCtx?.brsFile;
52✔
1283

1284
            const diagnostics = this
52✔
1285
                //get all current diagnostics (filtered by diagnostic filters)
1286
                .getDiagnostics()
1287
                //only keep diagnostics related to this file
1288
                .filter(x => x.file === effectiveFile)
89✔
1289
                //only keep diagnostics that touch this range
1290
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
66✔
1291

1292
            const scopes = this.getScopesForFile(effectiveFile);
52✔
1293

1294
            this.plugins.emit('onGetCodeActions', {
52✔
1295
                program: this,
1296
                file: effectiveFile,
1297
                range: range,
1298
                diagnostics: diagnostics,
1299
                scopes: scopes,
1300
                codeActions: codeActions
1301
            });
1302

1303
            this._cdataDiagnosticsContext = undefined;
52✔
1304

1305
            if (cdataCtx) {
52✔
1306
                this.remapCodeActionChangesToXml(codeActions, cdataCtx.brsFile, file as XmlFile);
1✔
1307
            }
1308
        }
1309
        return codeActions;
53✔
1310
    }
1311

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

1328
        // For XML files, also collect semantic tokens from each inline CDATA block.
1329
        // Ranges are already in XML coordinate space — no remapping needed.
1330
        if (isXmlFile(file)) {
17✔
1331
            for (const [pkgPath, meta] of this.syntheticFileMeta) {
1✔
1332
                if (meta.xmlFile !== file) {
2✔
1333
                    continue;
1✔
1334
                }
1335
                const brsFile = this.getFile<BrsFile>(pkgPath);
1✔
1336
                if (!brsFile) {
1!
NEW
1337
                    continue;
×
1338
                }
1339
                const cdataTokens = [] as SemanticToken[];
1✔
1340
                this.plugins.emit('onGetSemanticTokens', {
1✔
1341
                    program: this,
1342
                    file: brsFile,
1343
                    scopes: this.getScopesForFile(brsFile),
1344
                    semanticTokens: cdataTokens
1345
                });
1346
                result.push(...cdataTokens);
1✔
1347
            }
1348
        }
1349

1350
        return result;
17✔
1351
    }
1352

1353
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1354
        let file = this.getFile(filePath);
189✔
1355
        if (!file) {
189✔
1356
            return [];
2✔
1357
        }
1358
        const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
187✔
1359
        if (!isBrsFile(effectiveFile)) {
187✔
1360
            return [];
1✔
1361
        }
1362
        let callExpressionInfo = new CallExpressionInfo(effectiveFile, position);
186✔
1363
        let signatureHelpUtil = new SignatureHelpUtil();
186✔
1364
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
186✔
1365
    }
1366

1367
    public getReferences(srcPath: string, position: Position): Location[] {
1368
        //find the file
1369
        let file = this.getFile(srcPath);
5✔
1370
        if (!file) {
5!
1371
            return null;
×
1372
        }
1373

1374
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
5✔
1375
        const effectiveFile = cdataCtx?.brsFile ?? file;
5✔
1376

1377
        const event: ProvideReferencesEvent = {
5✔
1378
            program: this,
1379
            file: effectiveFile,
1380
            position: position,
1381
            references: []
1382
        };
1383

1384
        this.plugins.emit('beforeProvideReferences', event);
5✔
1385
        this.plugins.emit('provideReferences', event);
5✔
1386
        this.plugins.emit('afterProvideReferences', event);
5✔
1387

1388
        return cdataCtx ? this.remapLocationsFromSynthetic(event.references, cdataCtx) : event.references;
5✔
1389
    }
1390

1391
    /**
1392
     * Get a list of all script imports, relative to the specified pkgPath
1393
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1394
     */
1395
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1396
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1397

1398
        let result = [] as CompletionItem[];
3✔
1399
        /**
1400
         * hashtable to prevent duplicate results
1401
         */
1402
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1403

1404
        //restrict to only .brs files
1405
        for (let key in this.files) {
3✔
1406
            let file = this.files[key];
4✔
1407
            if (
4✔
1408
                //is a BrightScript or BrighterScript file
1409
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1410
                //this file is not the current file
1411
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1412
            ) {
1413
                //add the relative path
1414
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1415
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1416
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1417
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1418
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1419
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1420

1421
                    result.push({
3✔
1422
                        label: relativePath,
1423
                        detail: file.srcPath,
1424
                        kind: CompletionItemKind.File,
1425
                        textEdit: {
1426
                            newText: relativePath,
1427
                            range: scriptImport.filePathRange
1428
                        }
1429
                    });
1430

1431
                    //add the absolute path
1432
                    result.push({
3✔
1433
                        label: filePkgPath,
1434
                        detail: file.srcPath,
1435
                        kind: CompletionItemKind.File,
1436
                        textEdit: {
1437
                            newText: filePkgPath,
1438
                            range: scriptImport.filePathRange
1439
                        }
1440
                    });
1441
                }
1442
            }
1443
        }
1444
        return result;
3✔
1445
    }
1446

1447
    /**
1448
     * Transpile a single file and get the result as a string.
1449
     * This does not write anything to the file system.
1450
     *
1451
     * This should only be called by `LanguageServer`.
1452
     * Internal usage should call `_getTranspiledFileContents` instead.
1453
     * @param filePath can be a srcPath or a destPath
1454
     */
1455
    public async getTranspiledFileContents(filePath: string) {
1456
        const file = this.getFile(filePath);
4✔
1457
        const fileMap: FileObj[] = [{
4✔
1458
            src: file.srcPath,
1459
            dest: file.pkgPath
1460
        }];
1461
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1462
        const result = this._getTranspiledFileContents(
4✔
1463
            file
1464
        );
1465
        this.afterProgramTranspile(entries, astEditor);
4✔
1466
        return Promise.resolve(result);
4✔
1467
    }
1468

1469
    /**
1470
     * Transpile a synthetic CDATA BrsFile and return a SourceNode suitable for embedding
1471
     * back into the parent XML file's transpile output. Plugin beforeFileTranspile events are
1472
     * fired so AST edits (e.g. built-in transpile transforms) are applied. afterFileTranspile
1473
     * is intentionally skipped — it is designed for standalone file output, not embedded content.
1474
     *
1475
     * Because synthetic BrsFiles are created with offset-padded content, their token positions
1476
     * already align with positions in the parent XML file. Overriding state.srcPath to the XML
1477
     * file's path makes the resulting SourceNode reference the XML file directly, producing a
1478
     * correct unified source map.
1479
     */
1480
    public transpileSyntheticBrsFileToSourceNode(brsFile: BrsFile, xmlSrcPath: string): SourceNode {
1481
        const editor = new AstEditor();
7✔
1482

1483
        this.plugins.emit('beforeFileTranspile', {
7✔
1484
            program: this,
1485
            file: brsFile,
1486
            outputPath: undefined,
1487
            editor: editor
1488
        });
1489
        if (editor.hasChanges) {
7✔
1490
            editor.setProperty(brsFile, 'needsTranspiled', true);
5✔
1491
        }
1492

1493
        // Override srcPath so every SourceNode references the XML file at the correct position
1494
        const state = new BrsTranspileState(brsFile);
7✔
1495
        state.srcPath = xmlSrcPath;
7✔
1496

1497
        const sourceNode = util.sourceNodeFromTranspileResult(
7✔
1498
            null, null, state.srcPath, brsFile.ast.transpile(state)
1499
        );
1500

1501
        state.editor.undoAll();
7✔
1502
        editor.undoAll();
7✔
1503

1504
        return sourceNode;
7✔
1505
    }
1506

1507
    /**
1508
     * Internal function used to transpile files.
1509
     * This does not write anything to the file system
1510
     */
1511
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1512
        const editor = new AstEditor();
333✔
1513
        this.plugins.emit('beforeFileTranspile', {
333✔
1514
            program: this,
1515
            file: file,
1516
            outputPath: outputPath,
1517
            editor: editor
1518
        });
1519

1520
        //if we have any edits, assume the file needs to be transpiled
1521
        if (editor.hasChanges) {
333✔
1522
            //use the `editor` because it'll track the previous value for us and revert later on
1523
            editor.setProperty(file, 'needsTranspiled', true);
77✔
1524
        }
1525

1526
        //transpile the file
1527
        const result = file.transpile();
333✔
1528

1529
        //generate the typedef if enabled
1530
        let typedef: string;
1531
        if (isBrsFile(file) && this.options.emitDefinitions) {
333✔
1532
            typedef = file.getTypedef();
2✔
1533
        }
1534

1535
        const event: AfterFileTranspileEvent = {
333✔
1536
            program: this,
1537
            file: file,
1538
            outputPath: outputPath,
1539
            editor: editor,
1540
            code: result.code,
1541
            map: result.map,
1542
            typedef: typedef
1543
        };
1544
        this.plugins.emit('afterFileTranspile', event);
333✔
1545

1546
        //undo all `editor` edits that may have been applied to this file.
1547
        editor.undoAll();
333✔
1548

1549
        return {
333✔
1550
            srcPath: file.srcPath,
1551
            pkgPath: file.pkgPath,
1552
            code: event.code,
1553
            map: event.map,
1554
            typedef: event.typedef
1555
        };
1556
    }
1557

1558
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1559
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1560
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1561
            collection[s`${entry.src}`] = entry;
20✔
1562
            return collection;
20✔
1563
        }, {});
1564

1565
        const getOutputPath = (file: BscFile) => {
37✔
1566
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1567
            if (!filePathObj) {
77✔
1568
                //this file has been added in-memory, from a plugin, for example
1569
                filePathObj = {
47✔
1570
                    //add an interpolated src path (since it doesn't actually exist in memory)
1571
                    src: `bsc:/${file.pkgPath}`,
1572
                    dest: file.pkgPath
1573
                };
1574
            }
1575
            //replace the file extension
1576
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1577
            //prepend the staging folder path
1578
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1579
            return outputPath;
77✔
1580
        };
1581

1582
        const entries = Object.values(this.files)
37✔
1583
            //only include the files from fileEntries
1584
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1585
            .map(file => {
1586
                return {
18✔
1587
                    file: file,
1588
                    outputPath: getOutputPath(file)
1589
                };
1590
            })
1591
            //sort the entries to make transpiling more deterministic
1592
            .sort((a, b) => {
1593
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1594
            });
1595

1596
        const astEditor = new AstEditor();
37✔
1597

1598
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1599
        return {
37✔
1600
            entries: entries,
1601
            getOutputPath: getOutputPath,
1602
            astEditor: astEditor
1603
        };
1604
    }
1605

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

1609
        const processedFiles = new Set<string>();
32✔
1610

1611
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1612
            //find the file in the program
1613
            const file = this.getFile(srcPath);
35✔
1614
            //mark this file as processed so we don't process it more than once
1615
            processedFiles.add(outputPath?.toLowerCase());
35!
1616

1617
            //synthetic CDATA BrsFiles are embedded back into their parent XML — don't write them as separate output files
1618
            if (isBrsFile(file) && file.isSynthetic) {
35!
NEW
1619
                return;
×
1620
            }
1621

1622
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1623
                //skip transpiling typedef files
1624
                if (isBrsFile(file) && file.isTypedef) {
34✔
1625
                    return;
1✔
1626
                }
1627

1628
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1629

1630
                //make sure the full dir path exists
1631
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1632

1633
                if (await fsExtra.pathExists(outputPath)) {
33!
1634
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1635
                }
1636
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1637
                await Promise.all([
33✔
1638
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1639
                    writeMapPromise
1640
                ]);
1641

1642
                if (fileTranspileResult.typedef) {
33✔
1643
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1644
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1645
                }
1646
            }
1647
        };
1648

1649
        let promises = entries.map(async (entry) => {
32✔
1650
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1651
        });
1652

1653
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1654
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1655
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1656
        }
1657
        await Promise.all(promises);
32✔
1658

1659
        //transpile any new files that plugins added since the start of this transpile process
1660
        do {
32✔
1661
            promises = [];
52✔
1662
            for (const key in this.files) {
52✔
1663
                const file = this.files[key];
59✔
1664
                //this is a new file
1665
                const outputPath = getOutputPath(file);
59✔
1666
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1667
                    promises.push(
23✔
1668
                        transpileFile(file?.srcPath, outputPath)
69!
1669
                    );
1670
                }
1671
            }
1672
            if (promises.length > 0) {
52✔
1673
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1674
                await Promise.all(promises);
20✔
1675
            }
1676
        }
1677
        while (promises.length > 0);
1678
        this.afterProgramTranspile(entries, astEditor);
32✔
1679
    }
1680

1681
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1682
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1683
        astEditor.undoAll();
36✔
1684
    }
1685

1686
    /**
1687
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1688
     */
1689
    public findFilesForFunction(functionName: string) {
1690
        const files = [] as BscFile[];
33✔
1691
        const lowerFunctionName = functionName.toLowerCase();
33✔
1692
        //find every file with this function defined
1693
        for (const file of Object.values(this.files)) {
33✔
1694
            if (isBrsFile(file)) {
123✔
1695
                //TODO handle namespace-relative function calls
1696
                //if the file has a function with this name
1697
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
88✔
1698
                    files.push(file);
24✔
1699
                }
1700
            }
1701
        }
1702
        return files;
33✔
1703
    }
1704

1705
    /**
1706
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1707
     */
1708
    public findFilesForClass(className: string) {
1709
        const files = [] as BscFile[];
33✔
1710
        const lowerClassName = className.toLowerCase();
33✔
1711
        //find every file with this class defined
1712
        for (const file of Object.values(this.files)) {
33✔
1713
            if (isBrsFile(file)) {
123✔
1714
                //TODO handle namespace-relative classes
1715
                //if the file has a function with this name
1716
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
88✔
1717
                    files.push(file);
3✔
1718
                }
1719
            }
1720
        }
1721
        return files;
33✔
1722
    }
1723

1724
    public findFilesForNamespace(name: string) {
1725
        const files = [] as BscFile[];
33✔
1726
        const lowerName = name.toLowerCase();
33✔
1727
        //find every file with this class defined
1728
        for (const file of Object.values(this.files)) {
33✔
1729
            if (isBrsFile(file)) {
123✔
1730
                if (file.parser.references.namespaceStatements.find((x) => {
88✔
1731
                    const namespaceName = x.name.toLowerCase();
14✔
1732
                    return (
14✔
1733
                        //the namespace name matches exactly
1734
                        namespaceName === lowerName ||
18✔
1735
                        //the full namespace starts with the name (honoring the part boundary)
1736
                        namespaceName.startsWith(lowerName + '.')
1737
                    );
1738
                })) {
1739
                    files.push(file);
12✔
1740
                }
1741
            }
1742
        }
1743
        return files;
33✔
1744
    }
1745

1746
    public findFilesForEnum(name: string) {
1747
        const files = [] as BscFile[];
34✔
1748
        const lowerName = name.toLowerCase();
34✔
1749
        //find every file with this class defined
1750
        for (const file of Object.values(this.files)) {
34✔
1751
            if (isBrsFile(file)) {
124✔
1752
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
89✔
1753
                    files.push(file);
1✔
1754
                }
1755
            }
1756
        }
1757
        return files;
34✔
1758
    }
1759

1760
    private _manifest: Map<string, string>;
1761

1762
    /**
1763
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1764
     * @param parsedManifest The manifest map to read from and modify
1765
     */
1766
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1767
        // Lift the bs_consts defined in the manifest
1768
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1769

1770
        // Override or delete any bs_consts defined in the bs config
1771
        for (const key in this.options?.manifest?.bs_const) {
19!
1772
            const value = this.options.manifest.bs_const[key];
3✔
1773
            if (value === null) {
3✔
1774
                bsConsts.delete(key);
1✔
1775
            } else {
1776
                bsConsts.set(key, value);
2✔
1777
            }
1778
        }
1779

1780
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1781
        let constString = '';
19✔
1782
        for (const [key, value] of bsConsts) {
19✔
1783
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1784
        }
1785

1786
        // Set the updated bs_const value
1787
        parsedManifest.set('bs_const', constString);
19✔
1788
    }
1789

1790
    /**
1791
     * Try to find and load the manifest into memory
1792
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1793
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1794
     */
1795
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,052✔
1796
        //if we already have a manifest instance, and should not replace...then don't replace
1797
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,064!
1798
            return;
×
1799
        }
1800
        let manifestPath = manifestFileObj
1,064✔
1801
            ? manifestFileObj.src
1,064✔
1802
            : path.join(this.options.rootDir, 'manifest');
1803

1804
        try {
1,064✔
1805
            // we only load this manifest once, so do it sync to improve speed downstream
1806
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,064✔
1807
            const parsedManifest = parseManifest(contents);
19✔
1808
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1809
            this._manifest = parsedManifest;
19✔
1810
        } catch (e) {
1811
            this._manifest = new Map();
1,045✔
1812
        }
1813
    }
1814

1815
    /**
1816
     * Get a map of the manifest information
1817
     */
1818
    public getManifest() {
1819
        if (!this._manifest) {
1,396✔
1820
            this.loadManifest();
1,051✔
1821
        }
1822
        return this._manifest;
1,396✔
1823
    }
1824

1825
    public dispose() {
1826
        this.plugins.emit('beforeProgramDispose', { program: this });
1,354✔
1827

1828
        for (let filePath in this.files) {
1,354✔
1829
            this.files[filePath].dispose();
1,361✔
1830
        }
1831
        for (let name in this.scopes) {
1,354✔
1832
            this.scopes[name].dispose();
2,687✔
1833
        }
1834
        this.globalScope.dispose();
1,354✔
1835
        this.dependencyGraph.dispose();
1,354✔
1836
    }
1837
}
1838

1839
export interface FileTranspileResult {
1840
    srcPath: string;
1841
    pkgPath: string;
1842
    code: string;
1843
    map: SourceMapGenerator;
1844
    typedef: string;
1845
}
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