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

rokucommunity / brighterscript / #15526

31 Mar 2026 12:22AM UTC coverage: 88.96% (-0.08%) from 89.035%
#15526

push

web-flow
Merge 9a1249f1c into f3673e7df

8313 of 9843 branches covered (84.46%)

Branch coverage included in aggregate %.

173 of 193 new or added lines in 9 files covered. (89.64%)

2 existing lines in 2 files now uncovered.

10462 of 11262 relevant lines covered (92.9%)

2008.21 hits per line

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

91.93
/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,534✔
84
        this.logger = logger ?? createLogger(options);
1,534✔
85
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,534✔
86

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

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

93
        this.createGlobalScope();
1,534✔
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,534✔
102
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,534✔
103
        this.scopes.global = this.globalScope;
1,534✔
104
        //hardcode the files list for global scope to only contain the global file
105
        this.globalScope.getAllFiles = () => [globalFile];
23,474✔
106
        this.globalScope.validate();
1,534✔
107
        //for now, disable validation of global scope because the global files have some duplicate method declarations
108
        this.globalScope.getDiagnostics = () => [];
1,534✔
109
        //TODO we might need to fix this because the isValidated clears stuff now
110
        (this.globalScope as any).isValidated = true;
1,534✔
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,534✔
120

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

123
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,534✔
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,534✔
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,534✔
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,534✔
173
    private pkgMap = {} as Record<string, BscFile>;
1,534✔
174

175
    /**
176
     * When set, `getDiagnostics()` keeps this synthetic BrsFile's diagnostics associated with
177
     * the BrsFile rather than remapping them to the parent XmlFile. Set for the duration of any
178
     * plugin event whose `event.file` is a synthetic BrsFile, so that plugins calling
179
     * `program.getDiagnostics()` from inside the handler get results where
180
     * `x.file === event.file` works correctly (e.g. a plugin implementing "fix all").
181
     */
182
    private _cdataDiagnosticsContext: BrsFile | undefined;
183

184
    private scopes = {} as Record<string, Scope>;
1,534✔
185

186
    protected addScope(scope: Scope) {
187
        this.scopes[scope.name] = scope;
1,408✔
188
    }
189

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

198
    /**
199
     * Get the component with the specified name
200
     */
201
    public getComponent(componentName: string) {
202
        if (componentName) {
725✔
203
            //return the first compoment in the list with this name
204
            //(components are ordered in this list by pkgPath to ensure consistency)
205
            return this.components[componentName.toLowerCase()]?.[0];
682✔
206
        } else {
207
            return undefined;
43✔
208
        }
209
    }
210

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

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

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

261
            //attach (or re-attach) the dependencyGraph for every component whose position changed
262
            if (file.dependencyGraphIndex !== i) {
306✔
263
                file.dependencyGraphIndex = i;
302✔
264
                file.attachDependencyGraph(this.dependencyGraph);
302✔
265
                scope.attachDependencyGraph(this.dependencyGraph);
302✔
266
            }
267
        }
268
    }
269

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

287
    /**
288
     * Get the list of errors for the entire program. It's calculated on the fly
289
     * by walking through every file, so call this sparingly.
290
     */
291
    public getDiagnostics() {
292
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
1,022✔
293

294
            let diagnostics = [...this.diagnostics];
1,022✔
295

296
            //get the diagnostics from all scopes
297
            for (let scopeName in this.scopes) {
1,022✔
298
                let scope = this.scopes[scopeName];
2,082✔
299
                diagnostics.push(
2,082✔
300
                    ...scope.getDiagnostics()
301
                );
302
            }
303

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

320
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,022✔
321
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,022✔
322
            });
323

324
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
1,022✔
325
            return this._cdataDiagnosticsContext
1,022✔
326
                ? this.partialRemapSyntheticFileDiagnostics(filteredDiagnostics, this._cdataDiagnosticsContext)
1,022✔
327
                : this.remapSyntheticFileDiagnostics(filteredDiagnostics);
328
        });
329
    }
330

331
    /**
332
     * Redirects diagnostics from synthetic CDATA BrsFiles to their parent XmlFile.
333
     * Because synthetic files are created with offset-padded content, ranges are already
334
     * in XML coordinate space — only the file reference needs updating.
335
     */
336
    private remapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[]): BsDiagnostic[] {
337
        return diagnostics.map(diagnostic => {
1,019✔
338
            if (!diagnostic.file?.isSynthetic) {
543!
339
                return diagnostic;
541✔
340
            }
341
            const parentXmlFile = (diagnostic.file as BrsFile).parentXmlFile;
2✔
342
            if (!parentXmlFile) {
2!
NEW
343
                return diagnostic;
×
344
            }
345
            return { ...diagnostic, file: parentXmlFile };
2✔
346
        });
347
    }
348

349
    /**
350
     * Like `remapSyntheticFileDiagnostics`, but keeps `contextFile`'s diagnostics pointing
351
     * at the synthetic BrsFile so plugins can match by file identity during code action events.
352
     */
353
    private partialRemapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[], contextFile: BrsFile): BsDiagnostic[] {
354
        return diagnostics.map(diagnostic => {
3✔
355
            if (!diagnostic.file?.isSynthetic || diagnostic.file === contextFile) {
3!
356
                return diagnostic;
2✔
357
            }
358
            const parentXmlFile = (diagnostic.file as BrsFile).parentXmlFile;
1✔
359
            if (!parentXmlFile) {
1!
NEW
360
                return diagnostic;
×
361
            }
362
            return { ...diagnostic, file: parentXmlFile };
1✔
363
        });
364
    }
365

366
    /**
367
     * Emit a plugin event with `_cdataDiagnosticsContext` set for the duration if `file` is a
368
     * synthetic BrsFile. This ensures plugins that call `program.getDiagnostics()` from inside
369
     * the handler receive diagnostics still associated with the BrsFile, so that
370
     * `x.file === event.file` identity checks work correctly.
371
     */
372
    private emitWithSyntheticFileContext(file: BscFile | undefined, emit: () => void) {
373
        if (isBrsFile(file) && file.isSynthetic) {
2,589✔
374
            this._cdataDiagnosticsContext = file;
91✔
375
            try {
91✔
376
                emit();
91✔
377
            } finally {
378
                this._cdataDiagnosticsContext = undefined;
91✔
379
            }
380
        } else {
381
            emit();
2,498✔
382
        }
383
    }
384

385
    /**
386
     * Returns the CDATA metadata and corresponding synthetic BrsFile for the CDATA block whose
387
     * range intersects `range` within `xmlFile`, or `undefined` if no block matches.
388
     */
389
    private findCdataInfoForRange(xmlFile: XmlFile, range: Range): { meta: { xmlFile: XmlFile; cdataRange: Range }; brsFile: BrsFile } | undefined {
390
        for (const pkgPath of xmlFile.inlineScriptPkgPaths) {
28✔
391
            const brsFile = this.getFile<BrsFile>(pkgPath);
10✔
392
            if (!brsFile?.cdataScript?.cdata) {
10!
NEW
393
                continue;
×
394
            }
395
            if (util.rangesIntersectOrTouch(brsFile.cdataScript.cdata.range, range)) {
10✔
396
                return { meta: { xmlFile: xmlFile, cdataRange: brsFile.cdataScript.cdata.range }, brsFile: brsFile };
9✔
397
            }
398
        }
399
        return undefined;
19✔
400
    }
401

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

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

443
    /**
444
     * Substitutes the synthetic BrsFile URI with the parent XML file URI in any Location
445
     * entries that reference it. Ranges are already in XML coordinate space.
446
     */
447
    private remapLocationsFromSynthetic(locations: Location[], cdataCtx: CdataContext): Location[] {
448
        const syntheticUri = URI.file(cdataCtx.brsFile.srcPath).toString();
2✔
449
        const xmlUri = URI.file(cdataCtx.xmlFile.srcPath).toString();
2✔
450
        return locations.map(loc => (loc.uri === syntheticUri ? { uri: xmlUri, range: loc.range } : loc));
2!
451
    }
452

453
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
454
        this.diagnostics.push(...diagnostics);
45✔
455
    }
456

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

466
    public getPkgPath(...args: any[]): any { //eslint-disable-line
467
        throw new Error('Not implemented');
×
468
    }
469

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

484
    /**
485
     * Return all scopes
486
     */
487
    public getScopes() {
488
        return Object.values(this.scopes);
9✔
489
    }
490

491
    /**
492
     * Find the scope for the specified component
493
     */
494
    public getComponentScope(componentName: string) {
495
        return this.getComponent(componentName)?.scope;
216✔
496
    }
497

498
    /**
499
     * Update internal maps with this file reference
500
     */
501
    private assignFile<T extends BscFile = BscFile>(file: T) {
502
        this.files[file.srcPath.toLowerCase()] = file;
1,622✔
503
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,622✔
504
        return file;
1,622✔
505
    }
506

507
    /**
508
     * Remove this file from internal maps
509
     */
510
    private unassignFile<T extends BscFile = BscFile>(file: T) {
511
        delete this.files[file.srcPath.toLowerCase()];
202✔
512
        delete this.pkgMap[file.pkgPath.toLowerCase()];
202✔
513
        return file;
202✔
514
    }
515

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

535
    /**
536
     * Load a file into the program. If that file already exists, it is replaced.
537
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
538
     * @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:/`)
539
     * @param fileContents the file contents
540
     */
541
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
542
    /**
543
     * Load a file into the program. If that file already exists, it is replaced.
544
     * @param fileEntry an object that specifies src and dest for the file.
545
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
546
     */
547
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
548
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
549
        return this.setFileInternal<T>(fileParam, fileContents);
1,580✔
550
    }
551

552
    /**
553
     * Internal implementation of setFile that accepts optional BrsFile parse options.
554
     * The extra `parseOptions` parameter is intentionally not exposed on the public overloads — it is
555
     * only used when registering synthetic inline CDATA BrsFiles so that the lexer can start its
556
     * position tracking at the correct XML-space offset without needing a side-channel map.
557
     */
558
    private setFileInternal<T extends BscFile>(fileParam: FileObj | string, fileContents: string, parseOptions?: { startLine?: number; startCharacter?: number }, configure?: (file: BrsFile) => void): T {
559
        //normalize the file paths
560
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,626✔
561

562
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,626✔
563
            //if the file is already loaded, remove it
564
            if (this.hasFile(srcPath)) {
1,626✔
565
                this.removeFile(srcPath);
140✔
566
            }
567
            let fileExtension = path.extname(srcPath).toLowerCase();
1,626✔
568
            let file: BscFile | undefined;
569

570
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,626✔
571
                //add the file to the program
572
                const brsFile = this.assignFile(
1,322✔
573
                    new BrsFile(srcPath, pkgPath, this)
574
                );
575

576
                // Apply any caller-provided configuration (e.g. marking synthetic files) before
577
                // parsing so that `afterFileParse` fires with the correct state — allowing
578
                // `emitWithSyntheticFileContext` to set `_cdataDiagnosticsContext` for plugins
579
                // that call `getDiagnostics()` from inside the handler.
580
                configure?.(brsFile);
1,322✔
581

582
                //add file to the `source` dependency list
583
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,322✔
584
                    this.createSourceScope();
1,086✔
585
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,086✔
586
                }
587

588
                let sourceObj: SourceObj = {
1,322✔
589
                    //TODO remove `pathAbsolute` in v1
590
                    pathAbsolute: srcPath,
591
                    srcPath: srcPath,
592
                    source: fileContents
593
                };
594
                this.plugins.emit('beforeFileParse', sourceObj);
1,322✔
595

596
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,322✔
597
                    brsFile.parse(sourceObj.source, parseOptions);
1,322✔
598
                });
599

600
                //notify plugins that this file has finished parsing
601
                this.emitWithSyntheticFileContext(brsFile, () => this.plugins.emit('afterFileParse', brsFile));
1,322✔
602

603
                file = brsFile;
1,322✔
604

605
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,322✔
606

607
            } else if (
304✔
608
                //is xml file
609
                fileExtension === '.xml' &&
607✔
610
                //resides in the components folder (Roku will only parse xml files in the components folder)
611
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
612
            ) {
613
                //add the file to the program
614
                const xmlFile = this.assignFile(
300✔
615
                    new XmlFile(srcPath, pkgPath, this)
616
                );
617

618
                let sourceObj: SourceObj = {
300✔
619
                    //TODO remove `pathAbsolute` in v1
620
                    pathAbsolute: srcPath,
621
                    srcPath: srcPath,
622
                    source: fileContents
623
                };
624
                this.plugins.emit('beforeFileParse', sourceObj);
300✔
625

626
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
300✔
627
                    xmlFile.parse(sourceObj.source);
300✔
628
                });
629

630
                //notify plugins that this file has finished parsing
631
                this.plugins.emit('afterFileParse', xmlFile);
300✔
632

633
                file = xmlFile;
300✔
634

635
                //register synthetic BrsFiles for any inline CDATA script blocks.
636
                //these are treated as first-class files so all plugins (linters, validators, etc.) see them normally.
637
                let cdataScriptIndex = 0;
300✔
638
                for (const script of xmlFile.ast.component?.scripts ?? []) {
300✔
639
                    if (script.cdata) {
230✔
640
                        const inlinePkgPath = xmlFile.inlineScriptPkgPaths[cdataScriptIndex++];
46✔
641
                        // Pass the raw CDATA text directly as fileContents. startLine/startCharacter
642
                        // tell the lexer to start its position counters at the correct XML-space
643
                        // offset, so all token ranges are already in parent XML coordinate space.
644
                        // Consumers that need line-indexed text (e.g. SignatureHelpUtil) use the
645
                        // parentXmlFile's fileContents instead of the synthetic file's.
646
                        const cdataRange = script.cdata.range;
46✔
647
                        const contentStartChar = cdataRange.start.character + '<![CDATA['.length;
46✔
648
                        const rawSource = script.cdataText ?? '';
46!
649
                        const inlineFile = this.setFileInternal<BrsFile>(inlinePkgPath, rawSource, {
46✔
650
                            startLine: cdataRange.start.line,
651
                            startCharacter: contentStartChar
652
                        }, (file) => {
653
                            file.isSynthetic = true;
46✔
654
                        });
655
                        inlineFile.excludeFromOutput = true;
46✔
656
                        inlineFile.parentXmlFile = xmlFile;
46✔
657
                        inlineFile.cdataScript = script;
46✔
658
                        script.cdataTranspile = (state) => {
46✔
659
                            return inlineFile.needsTranspiled
11✔
660
                                ? this.transpileSyntheticBrsFileToSourceNode(inlineFile, state.srcPath)
11✔
661
                                : undefined;
662
                        };
663
                    }
664
                }
665

666
                //create a new scope for this xml file
667
                let scope = new XmlScope(xmlFile, this);
300✔
668
                this.addScope(scope);
300✔
669

670
                //register this compoent now that we have parsed it and know its component name
671
                this.registerComponent(xmlFile, scope);
300✔
672

673
                //notify plugins that the scope is created and the component is registered
674
                this.plugins.emit('afterScopeCreate', scope);
300✔
675
            } else {
676
                //TODO do we actually need to implement this? Figure out how to handle img paths
677
                // let genericFile = this.files[srcPath] = <any>{
678
                //     srcPath: srcPath,
679
                //     pkgPath: pkgPath,
680
                //     wasProcessed: true
681
                // } as File;
682
                // file = <any>genericFile;
683
            }
684
            return file;
1,626✔
685
        });
686
        return file as T;
1,626✔
687
    }
688

689
    /**
690
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
691
     * @param fileParam an object representing file paths
692
     * @param rootDir must be a pre-normalized path
693
     */
694
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
695
        let srcPath: string | undefined;
696
        let pkgPath: string | undefined;
697

698
        assert.ok(fileParam, 'fileParam is required');
1,633✔
699

700
        //lift the srcPath and pkgPath vars from the incoming param
701
        if (typeof fileParam === 'string') {
1,633✔
702
            fileParam = this.removePkgPrefix(fileParam);
1,171✔
703
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,171✔
704
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,171✔
705
        } else {
706
            let param: any = fileParam;
462✔
707

708
            if (param.src) {
462✔
709
                srcPath = s`${param.src}`;
459✔
710
            }
711
            if (param.srcPath) {
462✔
712
                srcPath = s`${param.srcPath}`;
2✔
713
            }
714
            if (param.dest) {
462✔
715
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
459✔
716
            }
717
            if (param.pkgPath) {
462✔
718
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
719
            }
720
        }
721

722
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
723
        if (!srcPath) {
1,633✔
724
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
725
        }
726
        //coerce srcPath to an absolute path
727
        if (!path.isAbsolute(srcPath)) {
1,633✔
728
            srcPath = util.standardizePath(srcPath);
1✔
729
        }
730

731
        //if there's no pkgPath, compute relative path from rootDir
732
        if (!pkgPath) {
1,633✔
733
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
734
        }
735

736
        assert.ok(srcPath, 'fileEntry.src is required');
1,633✔
737
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,633✔
738

739
        return {
1,633✔
740
            srcPath: srcPath,
741
            //remove leading slash from pkgPath
742
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
743
        };
744
    }
745

746
    /**
747
     * Remove any leading `pkg:/` found in the path
748
     */
749
    private removePkgPrefix(path: string) {
750
        return path.replace(/^pkg:\//i, '');
1,632✔
751
    }
752

753
    /**
754
     * Ensure source scope is created.
755
     * Note: automatically called internally, and no-op if it exists already.
756
     */
757
    public createSourceScope() {
758
        if (!this.scopes.source) {
1,493✔
759
            const sourceScope = new Scope('source', this, 'scope:source');
1,108✔
760
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,108✔
761
            this.addScope(sourceScope);
1,108✔
762
            this.plugins.emit('afterScopeCreate', sourceScope);
1,108✔
763
        }
764
    }
765

766
    /**
767
     * Find the file by its absolute path. This is case INSENSITIVE, since
768
     * Roku is a case insensitive file system. It is an error to have multiple files
769
     * with the same path with only case being different.
770
     * @param srcPath the absolute path to the file
771
     * @deprecated use `getFile` instead, which auto-detects the path type
772
     */
773
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
774
        srcPath = s`${srcPath}`;
×
775
        for (let filePath in this.files) {
×
776
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
777
                return this.files[filePath] as T;
×
778
            }
779
        }
780
    }
781

782
    /**
783
     * Get a list of files for the given (platform-normalized) pkgPath array.
784
     * Missing files are just ignored.
785
     * @deprecated use `getFiles` instead, which auto-detects the path types
786
     */
787
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
788
        return pkgPaths
×
789
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
790
            .filter(file => file !== undefined) as T;
×
791
    }
792

793
    /**
794
     * Get a file with the specified (platform-normalized) pkg path.
795
     * If not found, return undefined
796
     * @deprecated use `getFile` instead, which auto-detects the path type
797
     */
798
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
799
        return this.pkgMap[pkgPath.toLowerCase()] as T;
532✔
800
    }
801

802
    /**
803
     * Remove a set of files from the program
804
     * @param srcPaths can be an array of srcPath or destPath strings
805
     * @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
806
     */
807
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
808
        for (let srcPath of srcPaths) {
1✔
809
            this.removeFile(srcPath, normalizePath);
1✔
810
        }
811
    }
812

813
    /**
814
     * Remove a file from the program
815
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
816
     * @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
817
     */
818
    public removeFile(filePath: string, normalizePath = true) {
198✔
819
        this.logger.debug('Program.removeFile()', filePath);
202✔
820

821
        let file = this.getFile(filePath, normalizePath);
202✔
822
        if (file) {
202!
823
            this.plugins.emit('beforeFileDispose', file);
202✔
824

825
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
826
            let scope = this.scopes[file.pkgPath];
202✔
827
            if (scope) {
202✔
828
                this.plugins.emit('beforeScopeDispose', scope);
15✔
829
                scope.dispose();
15✔
830
                //notify dependencies of this scope that it has been removed
831
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
15✔
832
                delete this.scopes[file.pkgPath];
15✔
833
                this.plugins.emit('afterScopeDispose', scope);
15✔
834
            }
835
            //remove the file from the program
836
            this.unassignFile(file);
202✔
837

838
            this.dependencyGraph.remove(file.dependencyGraphKey);
202✔
839

840
            //if this is a pkg:/source file, notify the `source` scope that it has changed
841
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
202✔
842
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
101✔
843
            }
844

845
            //if this is a component, remove it from our components map
846
            if (isXmlFile(file)) {
202✔
847
                this.unregisterComponent(file);
15✔
848
            }
849
            //dispose file
850
            file?.dispose();
202!
851
            this.plugins.emit('afterFileDispose', file);
202✔
852
        }
853
    }
854

855
    /**
856
     * Counter used to track which validation run is being logged
857
     */
858
    private validationRunSequence = 1;
1,534✔
859

860
    /**
861
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
862
     */
863
    private validationMinSyncDuration = 75;
1,534✔
864

865
    private validatePromise: Promise<void> | undefined;
866

867
    /**
868
     * Traverse the entire project, and validate all scopes
869
     */
870
    public validate(): void;
871
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
872
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
873
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
874
        const validationRunId = this.validationRunSequence++;
970✔
875
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
970!
876

877
        let previousValidationPromise = this.validatePromise;
970✔
878
        const deferred = new Deferred();
970✔
879

880
        if (options?.async) {
970✔
881
            //we're async, so create a new promise chain to resolve after this validation is done
882
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
883
                return deferred.promise;
159✔
884
            });
885

886
            //we are not async but there's a pending promise, then we cannot run this validation
887
        } else if (previousValidationPromise !== undefined) {
811!
888
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
889
        }
890

891
        if (options?.async) {
970✔
892
            //we're async, so create a new promise chain to resolve after this validation is done
893
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
894
                return deferred.promise;
159✔
895
            });
896

897
            //we are not async but there's a pending promise, then we cannot run this validation
898
        } else if (previousValidationPromise !== undefined) {
811!
899
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
900
        }
901

902
        const sequencer = new Sequencer({
970✔
903
            name: 'program.validate',
904
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,820✔
905
            minSyncDuration: this.validationMinSyncDuration
906
        });
907

908
        let beforeProgramValidateWasEmitted = false;
970✔
909

910
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
911
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
912
        sequencer
970✔
913
            .once(() => {
914
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
915
                if (options?.async) {
970✔
916
                    return previousValidationPromise;
159✔
917
                }
918
            })
919
            .once(() => {
920
                this.diagnostics = [];
968✔
921
                this.plugins.emit('beforeProgramValidate', this);
968✔
922
                beforeProgramValidateWasEmitted = true;
968✔
923
            })
924
            .forEach(() => Object.values(this.files), (file) => {
968✔
925
                if (!file.isValidated) {
1,272✔
926
                    this.emitWithSyntheticFileContext(file, () => {
1,203✔
927
                        this.plugins.emit('beforeFileValidate', {
1,203✔
928
                            program: this,
929
                            file: file
930
                        });
931

932
                        //emit an event to allow plugins to contribute to the file validation process
933
                        this.plugins.emit('onFileValidate', {
1,203✔
934
                            program: this,
935
                            file: file
936
                        });
937
                        //call file.validate() IF the file has that function defined
938
                        file.validate?.();
1,203!
939
                        file.isValidated = true;
1,202✔
940

941
                        this.plugins.emit('afterFileValidate', file);
1,202✔
942
                    });
943
                }
944
            })
945
            .forEach(Object.values(this.scopes), (scope) => {
946
                scope.linkSymbolTable();
2,007✔
947
                scope.validate();
2,007✔
948
                scope.unlinkSymbolTable();
2,005✔
949
            })
950
            .once(() => {
951
                this.detectDuplicateComponentNames();
960✔
952
            })
953
            .onCancel(() => {
954
                timeEnd('cancelled');
10✔
955
            })
956
            .onSuccess(() => {
957
                timeEnd();
960✔
958
            })
959
            .onComplete(() => {
960
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
961
                if (beforeProgramValidateWasEmitted) {
970✔
962
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
968✔
963
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
968✔
964
                }
965

966
                //regardless of the success of the validation, mark this run as complete
967
                deferred.resolve();
970✔
968
                //clear the validatePromise which means we're no longer running a validation
969
                this.validatePromise = undefined;
970✔
970
            });
971

972
        //run the sequencer in async mode if enabled
973
        if (options?.async) {
970✔
974
            return sequencer.run();
159✔
975

976
            //run the sequencer in sync mode
977
        } else {
978
            return sequencer.runSync();
811✔
979
        }
980
    }
981

982
    /**
983
     * Flag all duplicate component names
984
     */
985
    private detectDuplicateComponentNames() {
986
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
960✔
987
            const file = this.files[filePath];
1,263✔
988
            //if this is an XmlFile, and it has a valid `componentName` property
989
            if (isXmlFile(file) && file.componentName?.text) {
1,263✔
990
                let lowerName = file.componentName.text.toLowerCase();
263✔
991
                if (!map[lowerName]) {
263✔
992
                    map[lowerName] = [];
260✔
993
                }
994
                map[lowerName].push(file);
263✔
995
            }
996
            return map;
1,263✔
997
        }, {});
998

999
        for (let name in componentsByName) {
960✔
1000
            const xmlFiles = componentsByName[name];
260✔
1001
            //add diagnostics for every duplicate component with this name
1002
            if (xmlFiles.length > 1) {
260✔
1003
                for (let xmlFile of xmlFiles) {
3✔
1004
                    const { componentName } = xmlFile;
6✔
1005
                    this.diagnostics.push({
6✔
1006
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1007
                        range: xmlFile.componentName.range,
1008
                        file: xmlFile,
1009
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1010
                            return {
6✔
1011
                                location: util.createLocation(
1012
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
1013
                                    x.componentName.range
1014
                                ),
1015
                                message: 'Also defined here'
1016
                            };
1017
                        })
1018
                    });
1019
                }
1020
            }
1021
        }
1022
    }
1023

1024
    /**
1025
     * Get the files for a list of filePaths
1026
     * @param filePaths can be an array of srcPath or a destPath strings
1027
     * @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
1028
     */
1029
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
34✔
1030
        return filePaths
34✔
1031
            .map(filePath => this.getFile(filePath, normalizePath))
42✔
1032
            .filter(file => file !== undefined) as T[];
42✔
1033
    }
1034

1035
    /**
1036
     * Get the file at the given path
1037
     * @param filePath can be a srcPath or a destPath
1038
     * @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
1039
     */
1040
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,589✔
1041
        if (typeof filePath !== 'string') {
8,493✔
1042
            return undefined;
1,739✔
1043
        } else if (path.isAbsolute(filePath)) {
6,754✔
1044
            return this.files[
3,519✔
1045
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,519✔
1046
            ] as T;
1047
        } else {
1048
            return this.pkgMap[
3,235✔
1049
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,235!
1050
            ] as T;
1051
        }
1052
    }
1053

1054
    /**
1055
     * Get a list of all scopes the file is loaded into
1056
     * @param file the file
1057
     */
1058
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1059

1060
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
609✔
1061

1062
        let result = [] as Scope[];
609✔
1063
        if (resolvedFile) {
609✔
1064
            for (let key in this.scopes) {
608✔
1065
                let scope = this.scopes[key];
1,270✔
1066

1067
                if (scope.hasFile(resolvedFile)) {
1,270✔
1068
                    result.push(scope);
619✔
1069
                }
1070
            }
1071
        }
1072
        return result;
609✔
1073
    }
1074

1075
    /**
1076
     * Get the first found scope for a file.
1077
     */
1078
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1079
        for (let key in this.scopes) {
2,826✔
1080
            let scope = this.scopes[key];
7,012✔
1081

1082
            if (scope.hasFile(file)) {
7,012✔
1083
                return scope;
2,685✔
1084
            }
1085
        }
1086
    }
1087

1088
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1089
        let results = new Map<Statement, FileLink<Statement>>();
41✔
1090
        const filesSearched = new Set<BrsFile>();
41✔
1091
        let lowerNamespaceName = namespaceName?.toLowerCase();
41✔
1092
        let lowerName = name?.toLowerCase();
41!
1093
        //look through all files in scope for matches
1094
        for (const scope of this.getScopesForFile(originFile)) {
41✔
1095
            for (const file of scope.getAllFiles()) {
41✔
1096
                if (isXmlFile(file) || filesSearched.has(file)) {
50✔
1097
                    continue;
5✔
1098
                }
1099
                filesSearched.add(file);
45✔
1100

1101
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
45✔
1102
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
102✔
1103
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
102✔
1104
                        if (!results.has(statement)) {
38!
1105
                            results.set(statement, { item: statement, file: file });
38✔
1106
                        }
1107
                    }
1108
                }
1109
            }
1110
        }
1111
        return [...results.values()];
41✔
1112
    }
1113

1114
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1115
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1116
        const filesSearched = new Set<BrsFile>();
8✔
1117

1118
        //get all function names for the xml file and parents
1119
        let funcNames = new Set<string>();
8✔
1120
        let currentScope = scope;
8✔
1121
        while (isXmlScope(currentScope)) {
8✔
1122
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1123
                if (!filterName || name === filterName) {
14!
1124
                    funcNames.add(name);
14✔
1125
                }
1126
            }
1127
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1128
        }
1129

1130
        //look through all files in scope for matches
1131
        for (const file of scope.getOwnFiles()) {
8✔
1132
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1133
                continue;
8✔
1134
            }
1135
            filesSearched.add(file);
8✔
1136

1137
            for (const statement of file.parser.references.functionStatements) {
8✔
1138
                if (funcNames.has(statement.name.text)) {
13!
1139
                    if (!results.has(statement)) {
13!
1140
                        results.set(statement, { item: statement, file: file });
13✔
1141
                    }
1142
                }
1143
            }
1144
        }
1145
        return [...results.values()];
8✔
1146
    }
1147

1148
    /**
1149
     * Find all available completion items at the given position
1150
     * @param filePath can be a srcPath or a destPath
1151
     * @param position the position (line & column) where completions should be found
1152
     */
1153
    public getCompletions(filePath: string, position: Position) {
1154
        let file = this.getFile(filePath);
79✔
1155
        if (!file) {
79!
1156
            return [];
×
1157
        }
1158

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

1161
        //find the scopes for this file
1162
        let scopes = this.getScopesForFile(effectiveFile);
79✔
1163

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

1167
        const event: ProvideCompletionsEvent = {
79✔
1168
            program: this,
1169
            file: effectiveFile,
1170
            scopes: scopes,
1171
            position: position,
1172
            completions: []
1173
        };
1174

1175
        this.plugins.emit('beforeProvideCompletions', event);
79✔
1176

1177
        this.plugins.emit('provideCompletions', event);
79✔
1178

1179
        this.plugins.emit('afterProvideCompletions', event);
79✔
1180

1181
        return event.completions;
79✔
1182
    }
1183

1184
    /**
1185
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1186
     */
1187
    public getWorkspaceSymbols() {
1188
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1189
            program: this,
1190
            workspaceSymbols: []
1191
        };
1192
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1193
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1194
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1195
        return event.workspaceSymbols;
22✔
1196
    }
1197

1198
    /**
1199
     * Given a position in a file, if the position is sitting on some type of identifier,
1200
     * go to the definition of that identifier (where this thing was first defined)
1201
     */
1202
    public getDefinition(srcPath: string, position: Position): Location[] {
1203
        let file = this.getFile(srcPath);
20✔
1204
        if (!file) {
20!
1205
            return [];
×
1206
        }
1207

1208
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
20✔
1209
        const effectiveFile = cdataCtx?.brsFile ?? file;
20✔
1210

1211
        const event: ProvideDefinitionEvent = {
20✔
1212
            program: this,
1213
            file: effectiveFile,
1214
            position: position,
1215
            definitions: []
1216
        };
1217

1218
        this.plugins.emit('beforeProvideDefinition', event);
20✔
1219
        this.plugins.emit('provideDefinition', event);
20✔
1220
        this.plugins.emit('afterProvideDefinition', event);
20✔
1221
        return cdataCtx ? this.remapLocationsFromSynthetic(event.definitions, cdataCtx) : event.definitions;
20✔
1222
    }
1223

1224
    /**
1225
     * Get hover information for a file and position
1226
     */
1227
    public getHover(srcPath: string, position: Position): Hover[] {
1228
        let file = this.getFile(srcPath);
33✔
1229
        let result: Hover[];
1230
        if (file) {
33!
1231
            const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
33!
1232

1233
            const event = {
33✔
1234
                program: this,
1235
                file: effectiveFile,
1236
                position: position,
1237
                scopes: this.getScopesForFile(effectiveFile),
1238
                hovers: []
1239
            } as ProvideHoverEvent;
1240
            this.plugins.emit('beforeProvideHover', event);
33✔
1241
            this.plugins.emit('provideHover', event);
33✔
1242
            this.plugins.emit('afterProvideHover', event);
33✔
1243

1244
            result = event.hovers;
33✔
1245
        }
1246

1247
        return result ?? [];
33!
1248
    }
1249

1250
    /**
1251
     * Get full list of document symbols for a file
1252
     * @param srcPath path to the file
1253
     */
1254
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1255
        let file = this.getFile(srcPath);
26✔
1256
        if (!file) {
26!
UNCOV
1257
            return undefined;
×
1258
        }
1259
        const event: ProvideDocumentSymbolsEvent = {
26✔
1260
            program: this,
1261
            file: file,
1262
            documentSymbols: []
1263
        };
1264
        this.plugins.emit('beforeProvideDocumentSymbols', event);
26✔
1265
        this.plugins.emit('provideDocumentSymbols', event);
26✔
1266
        this.plugins.emit('afterProvideDocumentSymbols', event);
26✔
1267

1268
        // For XML files, also collect symbols from each inline CDATA block.
1269
        // Ranges are already in XML coordinate space — no remapping needed.
1270
        if (isXmlFile(file)) {
26✔
1271
            for (const pkgPath of file.inlineScriptPkgPaths) {
3✔
1272
                const brsFile = this.getFile<BrsFile>(pkgPath);
2✔
1273
                if (!brsFile) {
2!
NEW
1274
                    continue;
×
1275
                }
1276
                const cdataEvent: ProvideDocumentSymbolsEvent = {
2✔
1277
                    program: this,
1278
                    file: brsFile,
1279
                    documentSymbols: []
1280
                };
1281
                this.emitWithSyntheticFileContext(brsFile, () => {
2✔
1282
                    this.plugins.emit('beforeProvideDocumentSymbols', cdataEvent);
2✔
1283
                    this.plugins.emit('provideDocumentSymbols', cdataEvent);
2✔
1284
                    this.plugins.emit('afterProvideDocumentSymbols', cdataEvent);
2✔
1285
                });
1286
                event.documentSymbols.push(...cdataEvent.documentSymbols);
2✔
1287
            }
1288
        }
1289

1290
        return event.documentSymbols;
26✔
1291
    }
1292

1293
    /**
1294
     * Compute code actions for the given file and range
1295
     */
1296
    public getCodeActions(srcPath: string, range: Range) {
1297
        const codeActions = [] as CodeAction[];
54✔
1298
        const file = this.getFile(srcPath);
54✔
1299
        if (file) {
54✔
1300
            // resolveCdataContext uses range.start as a probe point; findCdataInfoForRange
1301
            // is used internally to check intersection with the full range.
1302
            const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, range.start) : undefined;
53✔
1303

1304
            // When the range falls inside a CDATA block, redirect the event to the synthetic
1305
            // BrsFile so that BrsFile-specific code actions work correctly.
1306
            const effectiveFile = cdataCtx?.brsFile ?? file;
53✔
1307

1308
            const diagnostics = this
53✔
1309
                //get all current diagnostics (filtered by diagnostic filters)
1310
                .getDiagnostics()
1311
                //only keep diagnostics related to this file
1312
                .filter(x => x.file === effectiveFile)
90✔
1313
                //only keep diagnostics that touch this range
1314
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
66✔
1315

1316
            const scopes = this.getScopesForFile(effectiveFile);
53✔
1317

1318
            this.emitWithSyntheticFileContext(effectiveFile, () => {
53✔
1319
                this.plugins.emit('onGetCodeActions', {
53✔
1320
                    program: this,
1321
                    file: effectiveFile,
1322
                    range: range,
1323
                    diagnostics: diagnostics,
1324
                    scopes: scopes,
1325
                    codeActions: codeActions
1326
                });
1327
            });
1328

1329
            if (cdataCtx) {
53✔
1330
                this.remapCodeActionChangesToXml(codeActions, cdataCtx.brsFile, file as XmlFile);
2✔
1331
            }
1332
        }
1333
        return codeActions;
54✔
1334
    }
1335

1336
    /**
1337
     * Get semantic tokens for the specified file
1338
     */
1339
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1340
        const file = this.getFile(srcPath);
18✔
1341
        if (!file) {
18!
NEW
1342
            return undefined;
×
1343
        }
1344
        const result = [] as SemanticToken[];
18✔
1345
        this.plugins.emit('onGetSemanticTokens', {
18✔
1346
            program: this,
1347
            file: file,
1348
            scopes: this.getScopesForFile(file),
1349
            semanticTokens: result
1350
        });
1351

1352
        // For XML files, also collect semantic tokens from each inline CDATA block.
1353
        // Ranges are already in XML coordinate space — no remapping needed.
1354
        if (isXmlFile(file)) {
18✔
1355
            for (const pkgPath of file.inlineScriptPkgPaths) {
2✔
1356
                const brsFile = this.getFile<BrsFile>(pkgPath);
2✔
1357
                if (!brsFile) {
2!
NEW
1358
                    continue;
×
1359
                }
1360
                const cdataTokens = [] as SemanticToken[];
2✔
1361
                this.emitWithSyntheticFileContext(brsFile, () => {
2✔
1362
                    this.plugins.emit('onGetSemanticTokens', {
2✔
1363
                        program: this,
1364
                        file: brsFile,
1365
                        scopes: this.getScopesForFile(brsFile),
1366
                        semanticTokens: cdataTokens
1367
                    });
1368
                });
1369
                result.push(...cdataTokens);
2✔
1370
            }
1371
        }
1372

1373
        return result;
18✔
1374
    }
1375

1376
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1377
        let file = this.getFile(filePath);
190✔
1378
        if (!file) {
190✔
1379
            return [];
2✔
1380
        }
1381
        const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
188✔
1382
        if (!isBrsFile(effectiveFile)) {
188✔
1383
            return [];
1✔
1384
        }
1385
        let callExpressionInfo = new CallExpressionInfo(effectiveFile, position);
187✔
1386
        let signatureHelpUtil = new SignatureHelpUtil();
187✔
1387
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
187✔
1388
    }
1389

1390
    public getReferences(srcPath: string, position: Position): Location[] {
1391
        //find the file
1392
        let file = this.getFile(srcPath);
5✔
1393
        if (!file) {
5!
1394
            return null;
×
1395
        }
1396

1397
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
5✔
1398
        const effectiveFile = cdataCtx?.brsFile ?? file;
5✔
1399

1400
        const event: ProvideReferencesEvent = {
5✔
1401
            program: this,
1402
            file: effectiveFile,
1403
            position: position,
1404
            references: []
1405
        };
1406

1407
        this.plugins.emit('beforeProvideReferences', event);
5✔
1408
        this.plugins.emit('provideReferences', event);
5✔
1409
        this.plugins.emit('afterProvideReferences', event);
5✔
1410

1411
        return cdataCtx ? this.remapLocationsFromSynthetic(event.references, cdataCtx) : event.references;
5✔
1412
    }
1413

1414
    /**
1415
     * Get a list of all script imports, relative to the specified pkgPath
1416
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1417
     */
1418
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1419
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1420

1421
        let result = [] as CompletionItem[];
3✔
1422
        /**
1423
         * hashtable to prevent duplicate results
1424
         */
1425
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1426

1427
        //restrict to only .brs files
1428
        for (let key in this.files) {
3✔
1429
            let file = this.files[key];
4✔
1430
            if (
4✔
1431
                //is a BrightScript or BrighterScript file
1432
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1433
                //this file is not the current file
1434
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1435
            ) {
1436
                //add the relative path
1437
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1438
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1439
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1440
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1441
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1442
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1443

1444
                    result.push({
3✔
1445
                        label: relativePath,
1446
                        detail: file.srcPath,
1447
                        kind: CompletionItemKind.File,
1448
                        textEdit: {
1449
                            newText: relativePath,
1450
                            range: scriptImport.filePathRange
1451
                        }
1452
                    });
1453

1454
                    //add the absolute path
1455
                    result.push({
3✔
1456
                        label: filePkgPath,
1457
                        detail: file.srcPath,
1458
                        kind: CompletionItemKind.File,
1459
                        textEdit: {
1460
                            newText: filePkgPath,
1461
                            range: scriptImport.filePathRange
1462
                        }
1463
                    });
1464
                }
1465
            }
1466
        }
1467
        return result;
3✔
1468
    }
1469

1470
    /**
1471
     * Transpile a single file and get the result as a string.
1472
     * This does not write anything to the file system.
1473
     *
1474
     * This should only be called by `LanguageServer`.
1475
     * Internal usage should call `_getTranspiledFileContents` instead.
1476
     * @param filePath can be a srcPath or a destPath
1477
     */
1478
    public async getTranspiledFileContents(filePath: string) {
1479
        const file = this.getFile(filePath);
4✔
1480
        const fileMap: FileObj[] = [{
4✔
1481
            src: file.srcPath,
1482
            dest: file.pkgPath
1483
        }];
1484
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1485
        const result = this._getTranspiledFileContents(
4✔
1486
            file
1487
        );
1488
        this.afterProgramTranspile(entries, astEditor);
4✔
1489
        return Promise.resolve(result);
4✔
1490
    }
1491

1492
    /**
1493
     * Transpile a synthetic CDATA BrsFile and return a SourceNode suitable for embedding
1494
     * back into the parent XML file's transpile output. Plugin beforeFileTranspile events are
1495
     * fired so AST edits (e.g. built-in transpile transforms) are applied. afterFileTranspile
1496
     * is intentionally skipped — it is designed for standalone file output, not embedded content.
1497
     *
1498
     * Because synthetic BrsFiles are created with offset-padded content, their token positions
1499
     * already align with positions in the parent XML file. Overriding state.srcPath to the XML
1500
     * file's path makes the resulting SourceNode reference the XML file directly, producing a
1501
     * correct unified source map.
1502
     */
1503
    public transpileSyntheticBrsFileToSourceNode(brsFile: BrsFile, xmlSrcPath: string): SourceNode {
1504
        const editor = new AstEditor();
7✔
1505

1506
        this.emitWithSyntheticFileContext(brsFile, () => {
7✔
1507
            this.plugins.emit('beforeFileTranspile', {
7✔
1508
                program: this,
1509
                file: brsFile,
1510
                outputPath: undefined,
1511
                editor: editor
1512
            });
1513
        });
1514
        if (editor.hasChanges) {
7✔
1515
            editor.setProperty(brsFile, 'needsTranspiled', true);
5✔
1516
        }
1517

1518
        // Override srcPath so every SourceNode references the XML file at the correct position
1519
        const state = new BrsTranspileState(brsFile);
7✔
1520
        state.srcPath = xmlSrcPath;
7✔
1521

1522
        const sourceNode = util.sourceNodeFromTranspileResult(
7✔
1523
            null, null, state.srcPath, brsFile.ast.transpile(state)
1524
        );
1525

1526
        state.editor.undoAll();
7✔
1527
        editor.undoAll();
7✔
1528

1529
        return sourceNode;
7✔
1530
    }
1531

1532
    /**
1533
     * Internal function used to transpile files.
1534
     * This does not write anything to the file system
1535
     */
1536
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1537
        const editor = new AstEditor();
333✔
1538
        this.plugins.emit('beforeFileTranspile', {
333✔
1539
            program: this,
1540
            file: file,
1541
            outputPath: outputPath,
1542
            editor: editor
1543
        });
1544

1545
        //if we have any edits, assume the file needs to be transpiled
1546
        if (editor.hasChanges) {
333✔
1547
            //use the `editor` because it'll track the previous value for us and revert later on
1548
            editor.setProperty(file, 'needsTranspiled', true);
77✔
1549
        }
1550

1551
        //transpile the file
1552
        const result = file.transpile();
333✔
1553

1554
        //generate the typedef if enabled
1555
        let typedef: string;
1556
        if (isBrsFile(file) && this.options.emitDefinitions) {
333✔
1557
            typedef = file.getTypedef();
2✔
1558
        }
1559

1560
        const event: AfterFileTranspileEvent = {
333✔
1561
            program: this,
1562
            file: file,
1563
            outputPath: outputPath,
1564
            editor: editor,
1565
            code: result.code,
1566
            map: result.map,
1567
            typedef: typedef
1568
        };
1569
        this.plugins.emit('afterFileTranspile', event);
333✔
1570

1571
        //undo all `editor` edits that may have been applied to this file.
1572
        editor.undoAll();
333✔
1573

1574
        return {
333✔
1575
            srcPath: file.srcPath,
1576
            pkgPath: file.pkgPath,
1577
            code: event.code,
1578
            map: event.map,
1579
            typedef: event.typedef
1580
        };
1581
    }
1582

1583
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1584
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1585
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1586
            collection[s`${entry.src}`] = entry;
20✔
1587
            return collection;
20✔
1588
        }, {});
1589

1590
        const getOutputPath = (file: BscFile) => {
37✔
1591
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1592
            if (!filePathObj) {
77✔
1593
                //this file has been added in-memory, from a plugin, for example
1594
                filePathObj = {
47✔
1595
                    //add an interpolated src path (since it doesn't actually exist in memory)
1596
                    src: `bsc:/${file.pkgPath}`,
1597
                    dest: file.pkgPath
1598
                };
1599
            }
1600
            //replace the file extension
1601
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1602
            //prepend the staging folder path
1603
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1604
            return outputPath;
77✔
1605
        };
1606

1607
        const entries = Object.values(this.files)
37✔
1608
            //only include the files from fileEntries
1609
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1610
            .map(file => {
1611
                return {
18✔
1612
                    file: file,
1613
                    outputPath: getOutputPath(file)
1614
                };
1615
            })
1616
            //sort the entries to make transpiling more deterministic
1617
            .sort((a, b) => {
1618
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1619
            });
1620

1621
        const astEditor = new AstEditor();
37✔
1622

1623
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1624
        return {
37✔
1625
            entries: entries,
1626
            getOutputPath: getOutputPath,
1627
            astEditor: astEditor
1628
        };
1629
    }
1630

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

1634
        const processedFiles = new Set<string>();
32✔
1635

1636
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1637
            //find the file in the program
1638
            const file = this.getFile(srcPath);
35✔
1639
            //mark this file as processed so we don't process it more than once
1640
            processedFiles.add(outputPath?.toLowerCase());
35!
1641

1642
            if (file.excludeFromOutput) {
35!
NEW
1643
                return;
×
1644
            }
1645

1646
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1647
                //skip transpiling typedef files
1648
                if (isBrsFile(file) && file.isTypedef) {
34✔
1649
                    return;
1✔
1650
                }
1651

1652
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1653

1654
                //make sure the full dir path exists
1655
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1656

1657
                if (await fsExtra.pathExists(outputPath)) {
33!
1658
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1659
                }
1660
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1661
                await Promise.all([
33✔
1662
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1663
                    writeMapPromise
1664
                ]);
1665

1666
                if (fileTranspileResult.typedef) {
33✔
1667
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1668
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1669
                }
1670
            }
1671
        };
1672

1673
        let promises = entries.map(async (entry) => {
32✔
1674
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1675
        });
1676

1677
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1678
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1679
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1680
        }
1681
        await Promise.all(promises);
32✔
1682

1683
        //transpile any new files that plugins added since the start of this transpile process
1684
        do {
32✔
1685
            promises = [];
52✔
1686
            for (const key in this.files) {
52✔
1687
                const file = this.files[key];
59✔
1688
                //this is a new file
1689
                const outputPath = getOutputPath(file);
59✔
1690
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1691
                    promises.push(
23✔
1692
                        transpileFile(file?.srcPath, outputPath)
69!
1693
                    );
1694
                }
1695
            }
1696
            if (promises.length > 0) {
52✔
1697
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1698
                await Promise.all(promises);
20✔
1699
            }
1700
        }
1701
        while (promises.length > 0);
1702
        this.afterProgramTranspile(entries, astEditor);
32✔
1703
    }
1704

1705
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1706
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1707
        astEditor.undoAll();
36✔
1708
    }
1709

1710
    /**
1711
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1712
     */
1713
    public findFilesForFunction(functionName: string) {
1714
        const files = [] as BscFile[];
33✔
1715
        const lowerFunctionName = functionName.toLowerCase();
33✔
1716
        //find every file with this function defined
1717
        for (const file of Object.values(this.files)) {
33✔
1718
            if (isBrsFile(file)) {
123✔
1719
                //TODO handle namespace-relative function calls
1720
                //if the file has a function with this name
1721
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
88✔
1722
                    files.push(file);
24✔
1723
                }
1724
            }
1725
        }
1726
        return files;
33✔
1727
    }
1728

1729
    /**
1730
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1731
     */
1732
    public findFilesForClass(className: string) {
1733
        const files = [] as BscFile[];
33✔
1734
        const lowerClassName = className.toLowerCase();
33✔
1735
        //find every file with this class defined
1736
        for (const file of Object.values(this.files)) {
33✔
1737
            if (isBrsFile(file)) {
123✔
1738
                //TODO handle namespace-relative classes
1739
                //if the file has a function with this name
1740
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
88✔
1741
                    files.push(file);
3✔
1742
                }
1743
            }
1744
        }
1745
        return files;
33✔
1746
    }
1747

1748
    public findFilesForNamespace(name: string) {
1749
        const files = [] as BscFile[];
33✔
1750
        const lowerName = name.toLowerCase();
33✔
1751
        //find every file with this class defined
1752
        for (const file of Object.values(this.files)) {
33✔
1753
            if (isBrsFile(file)) {
123✔
1754
                if (file.parser.references.namespaceStatements.find((x) => {
88✔
1755
                    const namespaceName = x.name.toLowerCase();
14✔
1756
                    return (
14✔
1757
                        //the namespace name matches exactly
1758
                        namespaceName === lowerName ||
18✔
1759
                        //the full namespace starts with the name (honoring the part boundary)
1760
                        namespaceName.startsWith(lowerName + '.')
1761
                    );
1762
                })) {
1763
                    files.push(file);
12✔
1764
                }
1765
            }
1766
        }
1767
        return files;
33✔
1768
    }
1769

1770
    public findFilesForEnum(name: string) {
1771
        const files = [] as BscFile[];
34✔
1772
        const lowerName = name.toLowerCase();
34✔
1773
        //find every file with this class defined
1774
        for (const file of Object.values(this.files)) {
34✔
1775
            if (isBrsFile(file)) {
124✔
1776
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
89✔
1777
                    files.push(file);
1✔
1778
                }
1779
            }
1780
        }
1781
        return files;
34✔
1782
    }
1783

1784
    private _manifest: Map<string, string>;
1785

1786
    /**
1787
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1788
     * @param parsedManifest The manifest map to read from and modify
1789
     */
1790
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1791
        // Lift the bs_consts defined in the manifest
1792
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1793

1794
        // Override or delete any bs_consts defined in the bs config
1795
        for (const key in this.options?.manifest?.bs_const) {
19!
1796
            const value = this.options.manifest.bs_const[key];
3✔
1797
            if (value === null) {
3✔
1798
                bsConsts.delete(key);
1✔
1799
            } else {
1800
                bsConsts.set(key, value);
2✔
1801
            }
1802
        }
1803

1804
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1805
        let constString = '';
19✔
1806
        for (const [key, value] of bsConsts) {
19✔
1807
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1808
        }
1809

1810
        // Set the updated bs_const value
1811
        parsedManifest.set('bs_const', constString);
19✔
1812
    }
1813

1814
    /**
1815
     * Try to find and load the manifest into memory
1816
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1817
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1818
     */
1819
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,059✔
1820
        //if we already have a manifest instance, and should not replace...then don't replace
1821
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,071!
1822
            return;
×
1823
        }
1824
        let manifestPath = manifestFileObj
1,071✔
1825
            ? manifestFileObj.src
1,071✔
1826
            : path.join(this.options.rootDir, 'manifest');
1827

1828
        try {
1,071✔
1829
            // we only load this manifest once, so do it sync to improve speed downstream
1830
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,071✔
1831
            const parsedManifest = parseManifest(contents);
19✔
1832
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1833
            this._manifest = parsedManifest;
19✔
1834
        } catch (e) {
1835
            this._manifest = new Map();
1,052✔
1836
        }
1837
    }
1838

1839
    /**
1840
     * Get a map of the manifest information
1841
     */
1842
    public getManifest() {
1843
        if (!this._manifest) {
1,406✔
1844
            this.loadManifest();
1,058✔
1845
        }
1846
        return this._manifest;
1,406✔
1847
    }
1848

1849
    public dispose() {
1850
        this.plugins.emit('beforeProgramDispose', { program: this });
1,366✔
1851

1852
        for (let filePath in this.files) {
1,366✔
1853
            this.files[filePath].dispose();
1,370✔
1854
        }
1855
        for (let name in this.scopes) {
1,366✔
1856
            this.scopes[name].dispose();
2,707✔
1857
        }
1858
        this.globalScope.dispose();
1,366✔
1859
        this.dependencyGraph.dispose();
1,366✔
1860
    }
1861
}
1862

1863
export interface FileTranspileResult {
1864
    srcPath: string;
1865
    pkgPath: string;
1866
    code: string;
1867
    map: SourceMapGenerator;
1868
    typedef: string;
1869
}
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