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

rokucommunity / brighterscript / #15521

30 Mar 2026 11:34PM UTC coverage: 88.954% (-0.08%) from 89.035%
#15521

push

web-flow
Merge 99f3d8f7a into f3673e7df

8306 of 9837 branches covered (84.44%)

Branch coverage included in aggregate %.

176 of 196 new or added lines in 8 files covered. (89.8%)

2 existing lines in 2 files now uncovered.

10465 of 11265 relevant lines covered (92.9%)

2004.54 hits per line

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

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

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

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

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

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

123
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,528✔
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,528✔
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,528✔
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,528✔
173
    private pkgMap = {} as Record<string, BscFile>;
1,528✔
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
    /**
185
     * Set of absolute srcPaths for BrsFiles that are about to be registered as synthetic (CDATA)
186
     * files. Populated before `setFile` is called so that `isSynthetic` can be pre-applied inside
187
     * `setFile` before `afterFileParse` fires — otherwise `emitWithSyntheticFileContext` would see
188
     * `isSynthetic === false` and skip the diagnostic-context guard.
189
     */
190
    private _pendingSyntheticSrcPaths = new Set<string>();
1,528✔
191

192
    private scopes = {} as Record<string, Scope>;
1,528✔
193

194
    protected addScope(scope: Scope) {
195
        this.scopes[scope.name] = scope;
1,407✔
196
    }
197

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

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

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

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

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

269
            //attach (or re-attach) the dependencyGraph for every component whose position changed
270
            if (file.dependencyGraphIndex !== i) {
305✔
271
                file.dependencyGraphIndex = i;
301✔
272
                file.attachDependencyGraph(this.dependencyGraph);
301✔
273
                scope.attachDependencyGraph(this.dependencyGraph);
301✔
274
            }
275
        }
276
    }
277

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

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

302
            let diagnostics = [...this.diagnostics];
1,021✔
303

304
            //get the diagnostics from all scopes
305
            for (let scopeName in this.scopes) {
1,021✔
306
                let scope = this.scopes[scopeName];
2,080✔
307
                diagnostics.push(
2,080✔
308
                    ...scope.getDiagnostics()
309
                );
310
            }
311

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

328
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,021✔
329
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,021✔
330
            });
331

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

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

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

374
    /**
375
     * Emit a plugin event with `_cdataDiagnosticsContext` set for the duration if `file` is a
376
     * synthetic BrsFile. This ensures plugins that call `program.getDiagnostics()` from inside
377
     * the handler receive diagnostics still associated with the BrsFile, so that
378
     * `x.file === event.file` identity checks work correctly.
379
     */
380
    private emitWithSyntheticFileContext(file: BscFile | undefined, emit: () => void) {
381
        if (isBrsFile(file) && file.isSynthetic) {
2,584✔
382
            this._cdataDiagnosticsContext = file;
89✔
383
            try {
89✔
384
                emit();
89✔
385
            } finally {
386
                this._cdataDiagnosticsContext = undefined;
89✔
387
            }
388
        } else {
389
            emit();
2,495✔
390
        }
391
    }
392

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

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

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

451
    /**
452
     * Substitutes the synthetic BrsFile URI with the parent XML file URI in any Location
453
     * entries that reference it. Ranges are already in XML coordinate space.
454
     */
455
    private remapLocationsFromSynthetic(locations: Location[], cdataCtx: CdataContext): Location[] {
456
        const syntheticUri = URI.file(cdataCtx.brsFile.srcPath).toString();
2✔
457
        const xmlUri = URI.file(cdataCtx.xmlFile.srcPath).toString();
2✔
458
        return locations.map(loc => (loc.uri === syntheticUri ? { uri: xmlUri, range: loc.range } : loc));
2!
459
    }
460

461
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
462
        this.diagnostics.push(...diagnostics);
45✔
463
    }
464

465
    /**
466
     * Determine if the specified file is loaded in this program right now.
467
     * @param filePath the absolute or relative path to the file
468
     * @param normalizePath should the provided path be normalized before use
469
     */
470
    public hasFile(filePath: string, normalizePath = true) {
1,657✔
471
        return !!this.getFile(filePath, normalizePath);
1,657✔
472
    }
473

474
    public getPkgPath(...args: any[]): any { //eslint-disable-line
475
        throw new Error('Not implemented');
×
476
    }
477

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

492
    /**
493
     * Return all scopes
494
     */
495
    public getScopes() {
496
        return Object.values(this.scopes);
9✔
497
    }
498

499
    /**
500
     * Find the scope for the specified component
501
     */
502
    public getComponentScope(componentName: string) {
503
        return this.getComponent(componentName)?.scope;
215✔
504
    }
505

506
    /**
507
     * Update internal maps with this file reference
508
     */
509
    private assignFile<T extends BscFile = BscFile>(file: T) {
510
        this.files[file.srcPath.toLowerCase()] = file;
1,619✔
511
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,619✔
512
        return file;
1,619✔
513
    }
514

515
    /**
516
     * Remove this file from internal maps
517
     */
518
    private unassignFile<T extends BscFile = BscFile>(file: T) {
519
        delete this.files[file.srcPath.toLowerCase()];
201✔
520
        delete this.pkgMap[file.pkgPath.toLowerCase()];
201✔
521
        return file;
201✔
522
    }
523

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

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

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

570
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,623✔
571
            //if the file is already loaded, remove it
572
            if (this.hasFile(srcPath)) {
1,623✔
573
                this.removeFile(srcPath);
140✔
574
            }
575
            let fileExtension = path.extname(srcPath).toLowerCase();
1,623✔
576
            let file: BscFile | undefined;
577

578
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,623✔
579
                //add the file to the program
580
                const brsFile = this.assignFile(
1,320✔
581
                    new BrsFile(srcPath, pkgPath, this)
582
                );
583

584
                // Pre-mark as synthetic before parsing so that `afterFileParse` fires with
585
                // `isSynthetic === true`, allowing `emitWithSyntheticFileContext` to correctly
586
                // set `_cdataDiagnosticsContext` for any plugins that call `getDiagnostics()`.
587
                if (this._pendingSyntheticSrcPaths.has(srcPath)) {
1,320✔
588
                    brsFile.isSynthetic = true;
45✔
589
                }
590

591
                //add file to the `source` dependency list
592
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,320✔
593
                    this.createSourceScope();
1,086✔
594
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,086✔
595
                }
596

597
                let sourceObj: SourceObj = {
1,320✔
598
                    //TODO remove `pathAbsolute` in v1
599
                    pathAbsolute: srcPath,
600
                    srcPath: srcPath,
601
                    source: fileContents
602
                };
603
                this.plugins.emit('beforeFileParse', sourceObj);
1,320✔
604

605
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,320✔
606
                    brsFile.parse(sourceObj.source, parseOptions);
1,320✔
607
                });
608

609
                //notify plugins that this file has finished parsing
610
                this.emitWithSyntheticFileContext(brsFile, () => this.plugins.emit('afterFileParse', brsFile));
1,320✔
611

612
                file = brsFile;
1,320✔
613

614
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,320✔
615

616
            } else if (
303✔
617
                //is xml file
618
                fileExtension === '.xml' &&
605✔
619
                //resides in the components folder (Roku will only parse xml files in the components folder)
620
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
621
            ) {
622
                //add the file to the program
623
                const xmlFile = this.assignFile(
299✔
624
                    new XmlFile(srcPath, pkgPath, this)
625
                );
626

627
                let sourceObj: SourceObj = {
299✔
628
                    //TODO remove `pathAbsolute` in v1
629
                    pathAbsolute: srcPath,
630
                    srcPath: srcPath,
631
                    source: fileContents
632
                };
633
                this.plugins.emit('beforeFileParse', sourceObj);
299✔
634

635
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
299✔
636
                    xmlFile.parse(sourceObj.source);
299✔
637
                });
638

639
                //notify plugins that this file has finished parsing
640
                this.plugins.emit('afterFileParse', xmlFile);
299✔
641

642
                file = xmlFile;
299✔
643

644
                //register synthetic BrsFiles for any inline CDATA script blocks.
645
                //these are treated as first-class files so all plugins (linters, validators, etc.) see them normally.
646
                let cdataScriptIndex = 0;
299✔
647
                for (const script of xmlFile.ast.component?.scripts ?? []) {
299✔
648
                    if (script.cdata) {
228✔
649
                        const inlinePkgPath = xmlFile.inlineScriptPkgPaths[cdataScriptIndex++];
45✔
650
                        // The synthetic BrsFile needs two forms of the same source:
651
                        //
652
                        //  • fileContents = padded content (newlines + spaces prepended so that
653
                        //    XML-coordinate line numbers index correctly into the text). Tools
654
                        //    like SignatureHelpUtil use fileContents for line-based text extraction.
655
                        //
656
                        //  • rawSource = the actual cdataText passed to the lexer together with
657
                        //    startLine/startCharacter so that every token range is already in the
658
                        //    parent XML coordinate space — without the spurious Newline tokens that
659
                        //    the padding newlines would otherwise introduce into the token stream.
660
                        const cdataRange = script.cdata.range;
45✔
661
                        const contentStartChar = cdataRange.start.character + '<![CDATA['.length;
45✔
662
                        const rawSource = script.cdataText ?? '';
45!
663
                        const paddedContent = '\n'.repeat(cdataRange.start.line) +
45✔
664
                            ' '.repeat(contentStartChar) +
665
                            rawSource;
666
                        const inlineSrcPath = s`${path.resolve(this.options.rootDir, inlinePkgPath)}`;
45✔
667
                        this._pendingSyntheticSrcPaths.add(inlineSrcPath);
45✔
668
                        const inlineFile = this.setFileInternal<BrsFile>(inlinePkgPath, paddedContent, {
45✔
669
                            startLine: cdataRange.start.line,
670
                            startCharacter: contentStartChar,
671
                            rawSource: rawSource
672
                        });
673
                        this._pendingSyntheticSrcPaths.delete(inlineSrcPath);
45✔
674
                        // isSynthetic was pre-applied inside setFile (see _pendingSyntheticSrcPaths)
675
                        inlineFile.excludeFromOutput = true;
45✔
676
                        inlineFile.parentXmlFile = xmlFile;
45✔
677
                        inlineFile.cdataScript = script;
45✔
678
                        script.cdataTranspile = (state) => {
45✔
679
                            return inlineFile.needsTranspiled
11✔
680
                                ? this.transpileSyntheticBrsFileToSourceNode(inlineFile, state.srcPath)
11✔
681
                                : undefined;
682
                        };
683
                    }
684
                }
685

686
                //create a new scope for this xml file
687
                let scope = new XmlScope(xmlFile, this);
299✔
688
                this.addScope(scope);
299✔
689

690
                //register this compoent now that we have parsed it and know its component name
691
                this.registerComponent(xmlFile, scope);
299✔
692

693
                //notify plugins that the scope is created and the component is registered
694
                this.plugins.emit('afterScopeCreate', scope);
299✔
695
            } else {
696
                //TODO do we actually need to implement this? Figure out how to handle img paths
697
                // let genericFile = this.files[srcPath] = <any>{
698
                //     srcPath: srcPath,
699
                //     pkgPath: pkgPath,
700
                //     wasProcessed: true
701
                // } as File;
702
                // file = <any>genericFile;
703
            }
704
            return file;
1,623✔
705
        });
706
        return file as T;
1,623✔
707
    }
708

709
    /**
710
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
711
     * @param fileParam an object representing file paths
712
     * @param rootDir must be a pre-normalized path
713
     */
714
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
715
        let srcPath: string | undefined;
716
        let pkgPath: string | undefined;
717

718
        assert.ok(fileParam, 'fileParam is required');
1,630✔
719

720
        //lift the srcPath and pkgPath vars from the incoming param
721
        if (typeof fileParam === 'string') {
1,630✔
722
            fileParam = this.removePkgPrefix(fileParam);
1,168✔
723
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,168✔
724
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,168✔
725
        } else {
726
            let param: any = fileParam;
462✔
727

728
            if (param.src) {
462✔
729
                srcPath = s`${param.src}`;
459✔
730
            }
731
            if (param.srcPath) {
462✔
732
                srcPath = s`${param.srcPath}`;
2✔
733
            }
734
            if (param.dest) {
462✔
735
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
459✔
736
            }
737
            if (param.pkgPath) {
462✔
738
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
739
            }
740
        }
741

742
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
743
        if (!srcPath) {
1,630✔
744
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
745
        }
746
        //coerce srcPath to an absolute path
747
        if (!path.isAbsolute(srcPath)) {
1,630✔
748
            srcPath = util.standardizePath(srcPath);
1✔
749
        }
750

751
        //if there's no pkgPath, compute relative path from rootDir
752
        if (!pkgPath) {
1,630✔
753
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
754
        }
755

756
        assert.ok(srcPath, 'fileEntry.src is required');
1,630✔
757
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,630✔
758

759
        return {
1,630✔
760
            srcPath: srcPath,
761
            //remove leading slash from pkgPath
762
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
763
        };
764
    }
765

766
    /**
767
     * Remove any leading `pkg:/` found in the path
768
     */
769
    private removePkgPrefix(path: string) {
770
        return path.replace(/^pkg:\//i, '');
1,629✔
771
    }
772

773
    /**
774
     * Ensure source scope is created.
775
     * Note: automatically called internally, and no-op if it exists already.
776
     */
777
    public createSourceScope() {
778
        if (!this.scopes.source) {
1,493✔
779
            const sourceScope = new Scope('source', this, 'scope:source');
1,108✔
780
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,108✔
781
            this.addScope(sourceScope);
1,108✔
782
            this.plugins.emit('afterScopeCreate', sourceScope);
1,108✔
783
        }
784
    }
785

786
    /**
787
     * Find the file by its absolute path. This is case INSENSITIVE, since
788
     * Roku is a case insensitive file system. It is an error to have multiple files
789
     * with the same path with only case being different.
790
     * @param srcPath the absolute path to the file
791
     * @deprecated use `getFile` instead, which auto-detects the path type
792
     */
793
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
794
        srcPath = s`${srcPath}`;
×
795
        for (let filePath in this.files) {
×
796
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
797
                return this.files[filePath] as T;
×
798
            }
799
        }
800
    }
801

802
    /**
803
     * Get a list of files for the given (platform-normalized) pkgPath array.
804
     * Missing files are just ignored.
805
     * @deprecated use `getFiles` instead, which auto-detects the path types
806
     */
807
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
808
        return pkgPaths
×
809
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
810
            .filter(file => file !== undefined) as T;
×
811
    }
812

813
    /**
814
     * Get a file with the specified (platform-normalized) pkg path.
815
     * If not found, return undefined
816
     * @deprecated use `getFile` instead, which auto-detects the path type
817
     */
818
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
819
        return this.pkgMap[pkgPath.toLowerCase()] as T;
528✔
820
    }
821

822
    /**
823
     * Remove a set of files from the program
824
     * @param srcPaths can be an array of srcPath or destPath strings
825
     * @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
826
     */
827
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
828
        for (let srcPath of srcPaths) {
1✔
829
            this.removeFile(srcPath, normalizePath);
1✔
830
        }
831
    }
832

833
    /**
834
     * Remove a file from the program
835
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
836
     * @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
837
     */
838
    public removeFile(filePath: string, normalizePath = true) {
197✔
839
        this.logger.debug('Program.removeFile()', filePath);
201✔
840

841
        let file = this.getFile(filePath, normalizePath);
201✔
842
        if (file) {
201!
843
            this.plugins.emit('beforeFileDispose', file);
201✔
844

845
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
846
            let scope = this.scopes[file.pkgPath];
201✔
847
            if (scope) {
201✔
848
                this.plugins.emit('beforeScopeDispose', scope);
15✔
849
                scope.dispose();
15✔
850
                //notify dependencies of this scope that it has been removed
851
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
15✔
852
                delete this.scopes[file.pkgPath];
15✔
853
                this.plugins.emit('afterScopeDispose', scope);
15✔
854
            }
855
            //remove the file from the program
856
            this.unassignFile(file);
201✔
857

858
            this.dependencyGraph.remove(file.dependencyGraphKey);
201✔
859

860
            //if this is a pkg:/source file, notify the `source` scope that it has changed
861
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
201✔
862
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
101✔
863
            }
864

865
            //if this is a component, remove it from our components map
866
            if (isXmlFile(file)) {
201✔
867
                this.unregisterComponent(file);
15✔
868
            }
869
            //dispose file
870
            file?.dispose();
201!
871
            this.plugins.emit('afterFileDispose', file);
201✔
872
        }
873
    }
874

875
    /**
876
     * Counter used to track which validation run is being logged
877
     */
878
    private validationRunSequence = 1;
1,528✔
879

880
    /**
881
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
882
     */
883
    private validationMinSyncDuration = 75;
1,528✔
884

885
    private validatePromise: Promise<void> | undefined;
886

887
    /**
888
     * Traverse the entire project, and validate all scopes
889
     */
890
    public validate(): void;
891
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
892
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
893
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
894
        const validationRunId = this.validationRunSequence++;
969✔
895
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
969!
896

897
        let previousValidationPromise = this.validatePromise;
969✔
898
        const deferred = new Deferred();
969✔
899

900
        if (options?.async) {
969✔
901
            //we're async, so create a new promise chain to resolve after this validation is done
902
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
903
                return deferred.promise;
159✔
904
            });
905

906
            //we are not async but there's a pending promise, then we cannot run this validation
907
        } else if (previousValidationPromise !== undefined) {
810!
908
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
909
        }
910

911
        if (options?.async) {
969✔
912
            //we're async, so create a new promise chain to resolve after this validation is done
913
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
914
                return deferred.promise;
159✔
915
            });
916

917
            //we are not async but there's a pending promise, then we cannot run this validation
918
        } else if (previousValidationPromise !== undefined) {
810!
919
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
920
        }
921

922
        const sequencer = new Sequencer({
969✔
923
            name: 'program.validate',
924
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,814✔
925
            minSyncDuration: this.validationMinSyncDuration
926
        });
927

928
        let beforeProgramValidateWasEmitted = false;
969✔
929

930
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
931
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
932
        sequencer
969✔
933
            .once(() => {
934
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
935
                if (options?.async) {
969✔
936
                    return previousValidationPromise;
159✔
937
                }
938
            })
939
            .once(() => {
940
                this.diagnostics = [];
967✔
941
                this.plugins.emit('beforeProgramValidate', this);
967✔
942
                beforeProgramValidateWasEmitted = true;
967✔
943
            })
944
            .forEach(() => Object.values(this.files), (file) => {
967✔
945
                if (!file.isValidated) {
1,269✔
946
                    this.emitWithSyntheticFileContext(file, () => {
1,200✔
947
                        this.plugins.emit('beforeFileValidate', {
1,200✔
948
                            program: this,
949
                            file: file
950
                        });
951

952
                        //emit an event to allow plugins to contribute to the file validation process
953
                        this.plugins.emit('onFileValidate', {
1,200✔
954
                            program: this,
955
                            file: file
956
                        });
957
                        //call file.validate() IF the file has that function defined
958
                        file.validate?.();
1,200!
959
                        file.isValidated = true;
1,199✔
960

961
                        this.plugins.emit('afterFileValidate', file);
1,199✔
962
                    });
963
                }
964
            })
965
            .forEach(Object.values(this.scopes), (scope) => {
966
                scope.linkSymbolTable();
2,005✔
967
                scope.validate();
2,005✔
968
                scope.unlinkSymbolTable();
2,003✔
969
            })
970
            .once(() => {
971
                this.detectDuplicateComponentNames();
959✔
972
            })
973
            .onCancel(() => {
974
                timeEnd('cancelled');
10✔
975
            })
976
            .onSuccess(() => {
977
                timeEnd();
959✔
978
            })
979
            .onComplete(() => {
980
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
981
                if (beforeProgramValidateWasEmitted) {
969✔
982
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
967✔
983
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
967✔
984
                }
985

986
                //regardless of the success of the validation, mark this run as complete
987
                deferred.resolve();
969✔
988
                //clear the validatePromise which means we're no longer running a validation
989
                this.validatePromise = undefined;
969✔
990
            });
991

992
        //run the sequencer in async mode if enabled
993
        if (options?.async) {
969✔
994
            return sequencer.run();
159✔
995

996
            //run the sequencer in sync mode
997
        } else {
998
            return sequencer.runSync();
810✔
999
        }
1000
    }
1001

1002
    /**
1003
     * Flag all duplicate component names
1004
     */
1005
    private detectDuplicateComponentNames() {
1006
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
959✔
1007
            const file = this.files[filePath];
1,260✔
1008
            //if this is an XmlFile, and it has a valid `componentName` property
1009
            if (isXmlFile(file) && file.componentName?.text) {
1,260✔
1010
                let lowerName = file.componentName.text.toLowerCase();
262✔
1011
                if (!map[lowerName]) {
262✔
1012
                    map[lowerName] = [];
259✔
1013
                }
1014
                map[lowerName].push(file);
262✔
1015
            }
1016
            return map;
1,260✔
1017
        }, {});
1018

1019
        for (let name in componentsByName) {
959✔
1020
            const xmlFiles = componentsByName[name];
259✔
1021
            //add diagnostics for every duplicate component with this name
1022
            if (xmlFiles.length > 1) {
259✔
1023
                for (let xmlFile of xmlFiles) {
3✔
1024
                    const { componentName } = xmlFile;
6✔
1025
                    this.diagnostics.push({
6✔
1026
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1027
                        range: xmlFile.componentName.range,
1028
                        file: xmlFile,
1029
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1030
                            return {
6✔
1031
                                location: util.createLocation(
1032
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
1033
                                    x.componentName.range
1034
                                ),
1035
                                message: 'Also defined here'
1036
                            };
1037
                        })
1038
                    });
1039
                }
1040
            }
1041
        }
1042
    }
1043

1044
    /**
1045
     * Get the files for a list of filePaths
1046
     * @param filePaths can be an array of srcPath or a destPath strings
1047
     * @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
1048
     */
1049
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
34✔
1050
        return filePaths
34✔
1051
            .map(filePath => this.getFile(filePath, normalizePath))
42✔
1052
            .filter(file => file !== undefined) as T[];
42✔
1053
    }
1054

1055
    /**
1056
     * Get the file at the given path
1057
     * @param filePath can be a srcPath or a destPath
1058
     * @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
1059
     */
1060
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,579✔
1061
        if (typeof filePath !== 'string') {
8,479✔
1062
            return undefined;
1,739✔
1063
        } else if (path.isAbsolute(filePath)) {
6,740✔
1064
            return this.files[
3,511✔
1065
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,511✔
1066
            ] as T;
1067
        } else {
1068
            return this.pkgMap[
3,229✔
1069
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,229!
1070
            ] as T;
1071
        }
1072
    }
1073

1074
    /**
1075
     * Get a list of all scopes the file is loaded into
1076
     * @param file the file
1077
     */
1078
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1079

1080
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
608✔
1081

1082
        let result = [] as Scope[];
608✔
1083
        if (resolvedFile) {
608✔
1084
            for (let key in this.scopes) {
607✔
1085
                let scope = this.scopes[key];
1,268✔
1086

1087
                if (scope.hasFile(resolvedFile)) {
1,268✔
1088
                    result.push(scope);
618✔
1089
                }
1090
            }
1091
        }
1092
        return result;
608✔
1093
    }
1094

1095
    /**
1096
     * Get the first found scope for a file.
1097
     */
1098
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1099
        for (let key in this.scopes) {
2,821✔
1100
            let scope = this.scopes[key];
7,002✔
1101

1102
            if (scope.hasFile(file)) {
7,002✔
1103
                return scope;
2,680✔
1104
            }
1105
        }
1106
    }
1107

1108
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1109
        let results = new Map<Statement, FileLink<Statement>>();
40✔
1110
        const filesSearched = new Set<BrsFile>();
40✔
1111
        let lowerNamespaceName = namespaceName?.toLowerCase();
40✔
1112
        let lowerName = name?.toLowerCase();
40!
1113
        //look through all files in scope for matches
1114
        for (const scope of this.getScopesForFile(originFile)) {
40✔
1115
            for (const file of scope.getAllFiles()) {
40✔
1116
                if (isXmlFile(file) || filesSearched.has(file)) {
47✔
1117
                    continue;
4✔
1118
                }
1119
                filesSearched.add(file);
43✔
1120

1121
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
43✔
1122
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
100✔
1123
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
100✔
1124
                        if (!results.has(statement)) {
37!
1125
                            results.set(statement, { item: statement, file: file });
37✔
1126
                        }
1127
                    }
1128
                }
1129
            }
1130
        }
1131
        return [...results.values()];
40✔
1132
    }
1133

1134
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1135
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1136
        const filesSearched = new Set<BrsFile>();
8✔
1137

1138
        //get all function names for the xml file and parents
1139
        let funcNames = new Set<string>();
8✔
1140
        let currentScope = scope;
8✔
1141
        while (isXmlScope(currentScope)) {
8✔
1142
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1143
                if (!filterName || name === filterName) {
14!
1144
                    funcNames.add(name);
14✔
1145
                }
1146
            }
1147
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1148
        }
1149

1150
        //look through all files in scope for matches
1151
        for (const file of scope.getOwnFiles()) {
8✔
1152
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1153
                continue;
8✔
1154
            }
1155
            filesSearched.add(file);
8✔
1156

1157
            for (const statement of file.parser.references.functionStatements) {
8✔
1158
                if (funcNames.has(statement.name.text)) {
13!
1159
                    if (!results.has(statement)) {
13!
1160
                        results.set(statement, { item: statement, file: file });
13✔
1161
                    }
1162
                }
1163
            }
1164
        }
1165
        return [...results.values()];
8✔
1166
    }
1167

1168
    /**
1169
     * Find all available completion items at the given position
1170
     * @param filePath can be a srcPath or a destPath
1171
     * @param position the position (line & column) where completions should be found
1172
     */
1173
    public getCompletions(filePath: string, position: Position) {
1174
        let file = this.getFile(filePath);
79✔
1175
        if (!file) {
79!
1176
            return [];
×
1177
        }
1178

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

1181
        //find the scopes for this file
1182
        let scopes = this.getScopesForFile(effectiveFile);
79✔
1183

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

1187
        const event: ProvideCompletionsEvent = {
79✔
1188
            program: this,
1189
            file: effectiveFile,
1190
            scopes: scopes,
1191
            position: position,
1192
            completions: []
1193
        };
1194

1195
        this.plugins.emit('beforeProvideCompletions', event);
79✔
1196
        this.plugins.emit('provideCompletions', event);
79✔
1197
        this.plugins.emit('afterProvideCompletions', event);
79✔
1198

1199
        return event.completions;
79✔
1200
    }
1201

1202
    /**
1203
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1204
     */
1205
    public getWorkspaceSymbols() {
1206
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1207
            program: this,
1208
            workspaceSymbols: []
1209
        };
1210
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1211
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1212
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1213
        return event.workspaceSymbols;
22✔
1214
    }
1215

1216
    /**
1217
     * Given a position in a file, if the position is sitting on some type of identifier,
1218
     * go to the definition of that identifier (where this thing was first defined)
1219
     */
1220
    public getDefinition(srcPath: string, position: Position): Location[] {
1221
        let file = this.getFile(srcPath);
20✔
1222
        if (!file) {
20!
1223
            return [];
×
1224
        }
1225

1226
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
20✔
1227
        const effectiveFile = cdataCtx?.brsFile ?? file;
20✔
1228

1229
        const event: ProvideDefinitionEvent = {
20✔
1230
            program: this,
1231
            file: effectiveFile,
1232
            position: position,
1233
            definitions: []
1234
        };
1235

1236
        this.plugins.emit('beforeProvideDefinition', event);
20✔
1237
        this.plugins.emit('provideDefinition', event);
20✔
1238
        this.plugins.emit('afterProvideDefinition', event);
20✔
1239
        return cdataCtx ? this.remapLocationsFromSynthetic(event.definitions, cdataCtx) : event.definitions;
20✔
1240
    }
1241

1242
    /**
1243
     * Get hover information for a file and position
1244
     */
1245
    public getHover(srcPath: string, position: Position): Hover[] {
1246
        let file = this.getFile(srcPath);
33✔
1247
        let result: Hover[];
1248
        if (file) {
33!
1249
            const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
33!
1250

1251
            const event = {
33✔
1252
                program: this,
1253
                file: effectiveFile,
1254
                position: position,
1255
                scopes: this.getScopesForFile(effectiveFile),
1256
                hovers: []
1257
            } as ProvideHoverEvent;
1258
            this.plugins.emit('beforeProvideHover', event);
33✔
1259
            this.plugins.emit('provideHover', event);
33✔
1260
            this.plugins.emit('afterProvideHover', event);
33✔
1261

1262
            result = event.hovers;
33✔
1263
        }
1264

1265
        return result ?? [];
33!
1266
    }
1267

1268
    /**
1269
     * Get full list of document symbols for a file
1270
     * @param srcPath path to the file
1271
     */
1272
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1273
        let file = this.getFile(srcPath);
26✔
1274
        if (!file) {
26!
UNCOV
1275
            return undefined;
×
1276
        }
1277
        const event: ProvideDocumentSymbolsEvent = {
26✔
1278
            program: this,
1279
            file: file,
1280
            documentSymbols: []
1281
        };
1282
        this.plugins.emit('beforeProvideDocumentSymbols', event);
26✔
1283
        this.plugins.emit('provideDocumentSymbols', event);
26✔
1284
        this.plugins.emit('afterProvideDocumentSymbols', event);
26✔
1285

1286
        // For XML files, also collect symbols from each inline CDATA block.
1287
        // Ranges are already in XML coordinate space — no remapping needed.
1288
        if (isXmlFile(file)) {
26✔
1289
            for (const pkgPath of file.inlineScriptPkgPaths) {
3✔
1290
                const brsFile = this.getFile<BrsFile>(pkgPath);
2✔
1291
                if (!brsFile) {
2!
NEW
1292
                    continue;
×
1293
                }
1294
                const cdataEvent: ProvideDocumentSymbolsEvent = {
2✔
1295
                    program: this,
1296
                    file: brsFile,
1297
                    documentSymbols: []
1298
                };
1299
                this.emitWithSyntheticFileContext(brsFile, () => {
2✔
1300
                    this.plugins.emit('beforeProvideDocumentSymbols', cdataEvent);
2✔
1301
                    this.plugins.emit('provideDocumentSymbols', cdataEvent);
2✔
1302
                    this.plugins.emit('afterProvideDocumentSymbols', cdataEvent);
2✔
1303
                });
1304
                event.documentSymbols.push(...cdataEvent.documentSymbols);
2✔
1305
            }
1306
        }
1307

1308
        return event.documentSymbols;
26✔
1309
    }
1310

1311
    /**
1312
     * Compute code actions for the given file and range
1313
     */
1314
    public getCodeActions(srcPath: string, range: Range) {
1315
        const codeActions = [] as CodeAction[];
54✔
1316
        const file = this.getFile(srcPath);
54✔
1317
        if (file) {
54✔
1318
            // resolveCdataContext uses range.start as a probe point; findCdataInfoForRange
1319
            // is used internally to check intersection with the full range.
1320
            const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, range.start) : undefined;
53✔
1321

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

1326
            const diagnostics = this
53✔
1327
                //get all current diagnostics (filtered by diagnostic filters)
1328
                .getDiagnostics()
1329
                //only keep diagnostics related to this file
1330
                .filter(x => x.file === effectiveFile)
90✔
1331
                //only keep diagnostics that touch this range
1332
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
66✔
1333

1334
            const scopes = this.getScopesForFile(effectiveFile);
53✔
1335

1336
            this.emitWithSyntheticFileContext(effectiveFile, () => {
53✔
1337
                this.plugins.emit('onGetCodeActions', {
53✔
1338
                    program: this,
1339
                    file: effectiveFile,
1340
                    range: range,
1341
                    diagnostics: diagnostics,
1342
                    scopes: scopes,
1343
                    codeActions: codeActions
1344
                });
1345
            });
1346

1347
            if (cdataCtx) {
53✔
1348
                this.remapCodeActionChangesToXml(codeActions, cdataCtx.brsFile, file as XmlFile);
2✔
1349
            }
1350
        }
1351
        return codeActions;
54✔
1352
    }
1353

1354
    /**
1355
     * Get semantic tokens for the specified file
1356
     */
1357
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1358
        const file = this.getFile(srcPath);
18✔
1359
        if (!file) {
18!
NEW
1360
            return undefined;
×
1361
        }
1362
        const result = [] as SemanticToken[];
18✔
1363
        this.plugins.emit('onGetSemanticTokens', {
18✔
1364
            program: this,
1365
            file: file,
1366
            scopes: this.getScopesForFile(file),
1367
            semanticTokens: result
1368
        });
1369

1370
        // For XML files, also collect semantic tokens from each inline CDATA block.
1371
        // Ranges are already in XML coordinate space — no remapping needed.
1372
        if (isXmlFile(file)) {
18✔
1373
            for (const pkgPath of file.inlineScriptPkgPaths) {
2✔
1374
                const brsFile = this.getFile<BrsFile>(pkgPath);
2✔
1375
                if (!brsFile) {
2!
NEW
1376
                    continue;
×
1377
                }
1378
                const cdataTokens = [] as SemanticToken[];
2✔
1379
                this.emitWithSyntheticFileContext(brsFile, () => {
2✔
1380
                    this.plugins.emit('onGetSemanticTokens', {
2✔
1381
                        program: this,
1382
                        file: brsFile,
1383
                        scopes: this.getScopesForFile(brsFile),
1384
                        semanticTokens: cdataTokens
1385
                    });
1386
                });
1387
                result.push(...cdataTokens);
2✔
1388
            }
1389
        }
1390

1391
        return result;
18✔
1392
    }
1393

1394
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1395
        let file = this.getFile(filePath);
189✔
1396
        if (!file) {
189✔
1397
            return [];
2✔
1398
        }
1399
        const effectiveFile = isXmlFile(file) ? (this.resolveCdataContext(file, position)?.brsFile ?? file) : file;
187✔
1400
        if (!isBrsFile(effectiveFile)) {
187✔
1401
            return [];
1✔
1402
        }
1403
        let callExpressionInfo = new CallExpressionInfo(effectiveFile, position);
186✔
1404
        let signatureHelpUtil = new SignatureHelpUtil();
186✔
1405
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
186✔
1406
    }
1407

1408
    public getReferences(srcPath: string, position: Position): Location[] {
1409
        //find the file
1410
        let file = this.getFile(srcPath);
5✔
1411
        if (!file) {
5!
1412
            return null;
×
1413
        }
1414

1415
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
5✔
1416
        const effectiveFile = cdataCtx?.brsFile ?? file;
5✔
1417

1418
        const event: ProvideReferencesEvent = {
5✔
1419
            program: this,
1420
            file: effectiveFile,
1421
            position: position,
1422
            references: []
1423
        };
1424

1425
        this.plugins.emit('beforeProvideReferences', event);
5✔
1426
        this.plugins.emit('provideReferences', event);
5✔
1427
        this.plugins.emit('afterProvideReferences', event);
5✔
1428

1429
        return cdataCtx ? this.remapLocationsFromSynthetic(event.references, cdataCtx) : event.references;
5✔
1430
    }
1431

1432
    /**
1433
     * Get a list of all script imports, relative to the specified pkgPath
1434
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1435
     */
1436
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1437
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1438

1439
        let result = [] as CompletionItem[];
3✔
1440
        /**
1441
         * hashtable to prevent duplicate results
1442
         */
1443
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1444

1445
        //restrict to only .brs files
1446
        for (let key in this.files) {
3✔
1447
            let file = this.files[key];
4✔
1448
            if (
4✔
1449
                //is a BrightScript or BrighterScript file
1450
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1451
                //this file is not the current file
1452
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1453
            ) {
1454
                //add the relative path
1455
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1456
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1457
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1458
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1459
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1460
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1461

1462
                    result.push({
3✔
1463
                        label: relativePath,
1464
                        detail: file.srcPath,
1465
                        kind: CompletionItemKind.File,
1466
                        textEdit: {
1467
                            newText: relativePath,
1468
                            range: scriptImport.filePathRange
1469
                        }
1470
                    });
1471

1472
                    //add the absolute path
1473
                    result.push({
3✔
1474
                        label: filePkgPath,
1475
                        detail: file.srcPath,
1476
                        kind: CompletionItemKind.File,
1477
                        textEdit: {
1478
                            newText: filePkgPath,
1479
                            range: scriptImport.filePathRange
1480
                        }
1481
                    });
1482
                }
1483
            }
1484
        }
1485
        return result;
3✔
1486
    }
1487

1488
    /**
1489
     * Transpile a single file and get the result as a string.
1490
     * This does not write anything to the file system.
1491
     *
1492
     * This should only be called by `LanguageServer`.
1493
     * Internal usage should call `_getTranspiledFileContents` instead.
1494
     * @param filePath can be a srcPath or a destPath
1495
     */
1496
    public async getTranspiledFileContents(filePath: string) {
1497
        const file = this.getFile(filePath);
4✔
1498
        const fileMap: FileObj[] = [{
4✔
1499
            src: file.srcPath,
1500
            dest: file.pkgPath
1501
        }];
1502
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1503
        const result = this._getTranspiledFileContents(
4✔
1504
            file
1505
        );
1506
        this.afterProgramTranspile(entries, astEditor);
4✔
1507
        return Promise.resolve(result);
4✔
1508
    }
1509

1510
    /**
1511
     * Transpile a synthetic CDATA BrsFile and return a SourceNode suitable for embedding
1512
     * back into the parent XML file's transpile output. Plugin beforeFileTranspile events are
1513
     * fired so AST edits (e.g. built-in transpile transforms) are applied. afterFileTranspile
1514
     * is intentionally skipped — it is designed for standalone file output, not embedded content.
1515
     *
1516
     * Because synthetic BrsFiles are created with offset-padded content, their token positions
1517
     * already align with positions in the parent XML file. Overriding state.srcPath to the XML
1518
     * file's path makes the resulting SourceNode reference the XML file directly, producing a
1519
     * correct unified source map.
1520
     */
1521
    public transpileSyntheticBrsFileToSourceNode(brsFile: BrsFile, xmlSrcPath: string): SourceNode {
1522
        const editor = new AstEditor();
7✔
1523

1524
        this.emitWithSyntheticFileContext(brsFile, () => {
7✔
1525
            this.plugins.emit('beforeFileTranspile', {
7✔
1526
                program: this,
1527
                file: brsFile,
1528
                outputPath: undefined,
1529
                editor: editor
1530
            });
1531
        });
1532
        if (editor.hasChanges) {
7✔
1533
            editor.setProperty(brsFile, 'needsTranspiled', true);
5✔
1534
        }
1535

1536
        // Override srcPath so every SourceNode references the XML file at the correct position
1537
        const state = new BrsTranspileState(brsFile);
7✔
1538
        state.srcPath = xmlSrcPath;
7✔
1539

1540
        const sourceNode = util.sourceNodeFromTranspileResult(
7✔
1541
            null, null, state.srcPath, brsFile.ast.transpile(state)
1542
        );
1543

1544
        state.editor.undoAll();
7✔
1545
        editor.undoAll();
7✔
1546

1547
        return sourceNode;
7✔
1548
    }
1549

1550
    /**
1551
     * Internal function used to transpile files.
1552
     * This does not write anything to the file system
1553
     */
1554
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1555
        const editor = new AstEditor();
333✔
1556
        this.plugins.emit('beforeFileTranspile', {
333✔
1557
            program: this,
1558
            file: file,
1559
            outputPath: outputPath,
1560
            editor: editor
1561
        });
1562

1563
        //if we have any edits, assume the file needs to be transpiled
1564
        if (editor.hasChanges) {
333✔
1565
            //use the `editor` because it'll track the previous value for us and revert later on
1566
            editor.setProperty(file, 'needsTranspiled', true);
77✔
1567
        }
1568

1569
        //transpile the file
1570
        const result = file.transpile();
333✔
1571

1572
        //generate the typedef if enabled
1573
        let typedef: string;
1574
        if (isBrsFile(file) && this.options.emitDefinitions) {
333✔
1575
            typedef = file.getTypedef();
2✔
1576
        }
1577

1578
        const event: AfterFileTranspileEvent = {
333✔
1579
            program: this,
1580
            file: file,
1581
            outputPath: outputPath,
1582
            editor: editor,
1583
            code: result.code,
1584
            map: result.map,
1585
            typedef: typedef
1586
        };
1587
        this.plugins.emit('afterFileTranspile', event);
333✔
1588

1589
        //undo all `editor` edits that may have been applied to this file.
1590
        editor.undoAll();
333✔
1591

1592
        return {
333✔
1593
            srcPath: file.srcPath,
1594
            pkgPath: file.pkgPath,
1595
            code: event.code,
1596
            map: event.map,
1597
            typedef: event.typedef
1598
        };
1599
    }
1600

1601
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1602
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1603
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1604
            collection[s`${entry.src}`] = entry;
20✔
1605
            return collection;
20✔
1606
        }, {});
1607

1608
        const getOutputPath = (file: BscFile) => {
37✔
1609
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1610
            if (!filePathObj) {
77✔
1611
                //this file has been added in-memory, from a plugin, for example
1612
                filePathObj = {
47✔
1613
                    //add an interpolated src path (since it doesn't actually exist in memory)
1614
                    src: `bsc:/${file.pkgPath}`,
1615
                    dest: file.pkgPath
1616
                };
1617
            }
1618
            //replace the file extension
1619
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1620
            //prepend the staging folder path
1621
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1622
            return outputPath;
77✔
1623
        };
1624

1625
        const entries = Object.values(this.files)
37✔
1626
            //only include the files from fileEntries
1627
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1628
            .map(file => {
1629
                return {
18✔
1630
                    file: file,
1631
                    outputPath: getOutputPath(file)
1632
                };
1633
            })
1634
            //sort the entries to make transpiling more deterministic
1635
            .sort((a, b) => {
1636
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1637
            });
1638

1639
        const astEditor = new AstEditor();
37✔
1640

1641
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1642
        return {
37✔
1643
            entries: entries,
1644
            getOutputPath: getOutputPath,
1645
            astEditor: astEditor
1646
        };
1647
    }
1648

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

1652
        const processedFiles = new Set<string>();
32✔
1653

1654
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1655
            //find the file in the program
1656
            const file = this.getFile(srcPath);
35✔
1657
            //mark this file as processed so we don't process it more than once
1658
            processedFiles.add(outputPath?.toLowerCase());
35!
1659

1660
            if (file.excludeFromOutput) {
35!
NEW
1661
                return;
×
1662
            }
1663

1664
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1665
                //skip transpiling typedef files
1666
                if (isBrsFile(file) && file.isTypedef) {
34✔
1667
                    return;
1✔
1668
                }
1669

1670
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1671

1672
                //make sure the full dir path exists
1673
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1674

1675
                if (await fsExtra.pathExists(outputPath)) {
33!
1676
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1677
                }
1678
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1679
                await Promise.all([
33✔
1680
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1681
                    writeMapPromise
1682
                ]);
1683

1684
                if (fileTranspileResult.typedef) {
33✔
1685
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1686
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1687
                }
1688
            }
1689
        };
1690

1691
        let promises = entries.map(async (entry) => {
32✔
1692
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1693
        });
1694

1695
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1696
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1697
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1698
        }
1699
        await Promise.all(promises);
32✔
1700

1701
        //transpile any new files that plugins added since the start of this transpile process
1702
        do {
32✔
1703
            promises = [];
52✔
1704
            for (const key in this.files) {
52✔
1705
                const file = this.files[key];
59✔
1706
                //this is a new file
1707
                const outputPath = getOutputPath(file);
59✔
1708
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1709
                    promises.push(
23✔
1710
                        transpileFile(file?.srcPath, outputPath)
69!
1711
                    );
1712
                }
1713
            }
1714
            if (promises.length > 0) {
52✔
1715
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1716
                await Promise.all(promises);
20✔
1717
            }
1718
        }
1719
        while (promises.length > 0);
1720
        this.afterProgramTranspile(entries, astEditor);
32✔
1721
    }
1722

1723
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1724
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1725
        astEditor.undoAll();
36✔
1726
    }
1727

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

1747
    /**
1748
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1749
     */
1750
    public findFilesForClass(className: string) {
1751
        const files = [] as BscFile[];
33✔
1752
        const lowerClassName = className.toLowerCase();
33✔
1753
        //find every file with this class defined
1754
        for (const file of Object.values(this.files)) {
33✔
1755
            if (isBrsFile(file)) {
123✔
1756
                //TODO handle namespace-relative classes
1757
                //if the file has a function with this name
1758
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
88✔
1759
                    files.push(file);
3✔
1760
                }
1761
            }
1762
        }
1763
        return files;
33✔
1764
    }
1765

1766
    public findFilesForNamespace(name: string) {
1767
        const files = [] as BscFile[];
33✔
1768
        const lowerName = name.toLowerCase();
33✔
1769
        //find every file with this class defined
1770
        for (const file of Object.values(this.files)) {
33✔
1771
            if (isBrsFile(file)) {
123✔
1772
                if (file.parser.references.namespaceStatements.find((x) => {
88✔
1773
                    const namespaceName = x.name.toLowerCase();
14✔
1774
                    return (
14✔
1775
                        //the namespace name matches exactly
1776
                        namespaceName === lowerName ||
18✔
1777
                        //the full namespace starts with the name (honoring the part boundary)
1778
                        namespaceName.startsWith(lowerName + '.')
1779
                    );
1780
                })) {
1781
                    files.push(file);
12✔
1782
                }
1783
            }
1784
        }
1785
        return files;
33✔
1786
    }
1787

1788
    public findFilesForEnum(name: string) {
1789
        const files = [] as BscFile[];
34✔
1790
        const lowerName = name.toLowerCase();
34✔
1791
        //find every file with this class defined
1792
        for (const file of Object.values(this.files)) {
34✔
1793
            if (isBrsFile(file)) {
124✔
1794
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
89✔
1795
                    files.push(file);
1✔
1796
                }
1797
            }
1798
        }
1799
        return files;
34✔
1800
    }
1801

1802
    private _manifest: Map<string, string>;
1803

1804
    /**
1805
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1806
     * @param parsedManifest The manifest map to read from and modify
1807
     */
1808
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1809
        // Lift the bs_consts defined in the manifest
1810
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1811

1812
        // Override or delete any bs_consts defined in the bs config
1813
        for (const key in this.options?.manifest?.bs_const) {
19!
1814
            const value = this.options.manifest.bs_const[key];
3✔
1815
            if (value === null) {
3✔
1816
                bsConsts.delete(key);
1✔
1817
            } else {
1818
                bsConsts.set(key, value);
2✔
1819
            }
1820
        }
1821

1822
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1823
        let constString = '';
19✔
1824
        for (const [key, value] of bsConsts) {
19✔
1825
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1826
        }
1827

1828
        // Set the updated bs_const value
1829
        parsedManifest.set('bs_const', constString);
19✔
1830
    }
1831

1832
    /**
1833
     * Try to find and load the manifest into memory
1834
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1835
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1836
     */
1837
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,058✔
1838
        //if we already have a manifest instance, and should not replace...then don't replace
1839
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,070!
1840
            return;
×
1841
        }
1842
        let manifestPath = manifestFileObj
1,070✔
1843
            ? manifestFileObj.src
1,070✔
1844
            : path.join(this.options.rootDir, 'manifest');
1845

1846
        try {
1,070✔
1847
            // we only load this manifest once, so do it sync to improve speed downstream
1848
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,070✔
1849
            const parsedManifest = parseManifest(contents);
19✔
1850
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1851
            this._manifest = parsedManifest;
19✔
1852
        } catch (e) {
1853
            this._manifest = new Map();
1,051✔
1854
        }
1855
    }
1856

1857
    /**
1858
     * Get a map of the manifest information
1859
     */
1860
    public getManifest() {
1861
        if (!this._manifest) {
1,404✔
1862
            this.loadManifest();
1,057✔
1863
        }
1864
        return this._manifest;
1,404✔
1865
    }
1866

1867
    public dispose() {
1868
        this.plugins.emit('beforeProgramDispose', { program: this });
1,360✔
1869

1870
        for (let filePath in this.files) {
1,360✔
1871
            this.files[filePath].dispose();
1,368✔
1872
        }
1873
        for (let name in this.scopes) {
1,360✔
1874
            this.scopes[name].dispose();
2,700✔
1875
        }
1876
        this.globalScope.dispose();
1,360✔
1877
        this.dependencyGraph.dispose();
1,360✔
1878
    }
1879
}
1880

1881
export interface FileTranspileResult {
1882
    srcPath: string;
1883
    pkgPath: string;
1884
    code: string;
1885
    map: SourceMapGenerator;
1886
    typedef: string;
1887
}
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