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

rokucommunity / brighterscript / #15504

28 Mar 2026 09:27PM UTC coverage: 88.85% (-0.2%) from 89.035%
#15504

push

web-flow
Merge e5d28fbf0 into f3673e7df

8276 of 9814 branches covered (84.33%)

Branch coverage included in aggregate %.

153 of 184 new or added lines in 5 files covered. (83.15%)

7 existing lines in 3 files now uncovered.

10458 of 11271 relevant lines covered (92.79%)

1984.88 hits per line

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

90.08
/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 { SourceMapGenerator } from 'source-map';
28
import type { Statement } from './parser/AstNode';
29
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
30
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
31
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
1✔
32
import { Sequencer } from './common/Sequencer';
1✔
33
import { Deferred } from './deferred';
1✔
34

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

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

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

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

60
/**
61
 * Coordinate-space remapping context for a single CDATA block. Returned by
62
 * `Program.resolveCdataContext` when a position falls inside an inline script block.
63
 * Provides bound helpers to convert positions between the parent XML file's coordinate
64
 * space and the synthetic BrsFile's coordinate space.
65
 */
66
export interface CdataContext {
67
    brsFile: BrsFile;
68
    xmlFile: XmlFile;
69
    cdataRange: Range;
70
    /** Convert a position from parent XML coordinates to synthetic BrsFile coordinates. */
71
    toSynthetic(pos: Position): Position;
72
    /** Convert a position from synthetic BrsFile coordinates back to parent XML coordinates. */
73
    fromSynthetic(pos: Position): Position;
74
}
75

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

89
        //inject the bsc plugin as the first plugin in the stack.
90
        this.plugins.addFirst(new BscPlugin());
1,514✔
91

92
        //normalize the root dir path
93
        this.options.rootDir = util.getRootDir(this.options);
1,514✔
94

95
        this.createGlobalScope();
1,514✔
96
    }
97

98
    public options: FinalizedBsConfig;
99
    public logger: Logger;
100

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

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

123
    private diagnosticFilterer = new DiagnosticFilterer();
1,514✔
124

125
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,514✔
126

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

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

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

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

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

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

162
    public get bslibPrefix() {
163
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
428✔
164
            return 'rokucommunity_bslib';
3✔
165
        } else {
166
            return 'bslib';
425✔
167
        }
168
    }
169

170

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

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

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

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

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

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

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

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

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

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

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

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

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

301
            let diagnostics = [...this.diagnostics];
1,012✔
302

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

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

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

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

338
    /**
339
     * Remaps diagnostics from synthetic CDATA BrsFiles back to their parent XmlFile at the
340
     * correct position. `<![CDATA[` is 9 characters; positions on the first line of the CDATA
341
     * token need that offset added. Subsequent lines are column-accurate already.
342
     */
343
    private remapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[]): BsDiagnostic[] {
344
        return diagnostics.map(diagnostic => {
1,011✔
345
            if (!diagnostic.file?.isSynthetic) {
542!
346
                return diagnostic;
541✔
347
            }
348
            const meta = this.syntheticFileMeta.get(diagnostic.file.pkgPath.toLowerCase());
1✔
349
            if (!meta) {
1!
NEW
350
                return diagnostic;
×
351
            }
352
            return {
1✔
353
                ...diagnostic,
354
                file: meta.xmlFile,
355
                range: {
356
                    start: this.remapPosFromSynthetic(meta.cdataRange, diagnostic.range.start),
357
                    end: this.remapPosFromSynthetic(meta.cdataRange, diagnostic.range.end)
358
                }
359
            };
360
        });
361
    }
362

363
    /**
364
     * Like `remapSyntheticFileDiagnostics`, but skips remapping for `contextFile` so its
365
     * diagnostics remain in synthetic-file coordinate space. Used during code action events
366
     * so that plugins can match diagnostics by file identity (`x.file === event.file`).
367
     */
368
    private partialRemapSyntheticFileDiagnostics(diagnostics: BsDiagnostic[], contextFile: BrsFile): BsDiagnostic[] {
369
        return diagnostics.map(diagnostic => {
1✔
NEW
370
            if (!diagnostic.file?.isSynthetic) {
×
NEW
371
                return diagnostic;
×
372
            }
373
            // Keep the context file's diagnostics in synthetic coordinate space
NEW
374
            if (diagnostic.file === contextFile) {
×
NEW
375
                return diagnostic;
×
376
            }
377
            // All other synthetic files remap to XML as usual
NEW
378
            const meta = this.syntheticFileMeta.get(diagnostic.file.pkgPath.toLowerCase());
×
NEW
379
            if (!meta) {
×
NEW
380
                return diagnostic;
×
381
            }
NEW
382
            return {
×
383
                ...diagnostic,
384
                file: meta.xmlFile,
385
                range: {
386
                    start: this.remapPosFromSynthetic(meta.cdataRange, diagnostic.range.start),
387
                    end: this.remapPosFromSynthetic(meta.cdataRange, diagnostic.range.end)
388
                }
389
            };
390
        });
391
    }
392

393
    /**
394
     * Converts a position in synthetic BrsFile coordinates to its equivalent position in the
395
     * parent XML file. Line 0 of the synthetic file maps to the line containing `<![CDATA[`,
396
     * offset by 9 characters (the length of `<![CDATA[`). Subsequent lines are column-accurate.
397
     */
398
    private remapPosFromSynthetic(cdataRange: Range, pos: Position): Position {
399
        const cdataStartLine = cdataRange.start.line;
24✔
400
        const cdataContentStartChar = cdataRange.start.character + '<![CDATA['.length;
24✔
401
        return {
24✔
402
            line: cdataStartLine + pos.line,
403
            character: pos.line === 0 ? cdataContentStartChar + pos.character : pos.character
24✔
404
        };
405
    }
406

407
    /**
408
     * Converts a position in the parent XML file to the equivalent position in the synthetic
409
     * BrsFile coordinate space (inverse of `remapPosFromSynthetic`).
410
     */
411
    private remapPosToSynthetic(cdataRange: Range, pos: Position): Position {
412
        const cdataStartLine = cdataRange.start.line;
9✔
413
        const cdataContentStartChar = cdataRange.start.character + '<![CDATA['.length;
9✔
414
        const syntheticLine = pos.line - cdataStartLine;
9✔
415
        return {
9✔
416
            line: syntheticLine,
417
            character: syntheticLine === 0 ? pos.character - cdataContentStartChar : pos.character
9✔
418
        };
419
    }
420

421
    /**
422
     * Returns the CDATA metadata and corresponding synthetic BrsFile for the CDATA block whose
423
     * range intersects `range` within `xmlFile`, or `undefined` if no block matches.
424
     */
425
    private findCdataInfoForRange(xmlFile: XmlFile, range: Range): { meta: { xmlFile: XmlFile; cdataRange: Range }; brsFile: BrsFile } | undefined {
426
        for (const [pkgPathKey, meta] of this.syntheticFileMeta) {
29✔
427
            if (meta.xmlFile !== xmlFile) {
13✔
428
                continue;
2✔
429
            }
430
            if (util.rangesIntersectOrTouch(meta.cdataRange, range)) {
11✔
431
                const brsFile = this.getFile<BrsFile>(pkgPathKey);
10✔
432
                if (brsFile) {
10!
433
                    return { meta: meta, brsFile: brsFile };
10✔
434
                }
435
            }
436
        }
437
        return undefined;
19✔
438
    }
439

440
    /**
441
     * After code actions are generated using a synthetic BrsFile as the target, this remaps any
442
     * workspace edit changes that reference the synthetic file back to the parent XML file, with
443
     * positions converted from BrsFile coordinates to XML coordinates.
444
     */
445
    private remapCodeActionChangesToXml(
446
        codeActions: CodeAction[],
447
        cdataCtx: CdataContext,
448
        brsFile: BrsFile,
449
        xmlFile: XmlFile
450
    ) {
451
        const syntheticUri = URI.file(brsFile.srcPath).toString();
1✔
452
        const xmlUri = URI.file(xmlFile.srcPath).toString();
1✔
453
        for (const action of codeActions) {
1✔
NEW
454
            const changes = (action.edit as WorkspaceEdit)?.changes;
×
NEW
455
            if (!changes?.[syntheticUri]) {
×
NEW
456
                continue;
×
457
            }
NEW
458
            const syntheticEdits = changes[syntheticUri];
×
NEW
459
            delete changes[syntheticUri];
×
NEW
460
            if (!changes[xmlUri]) {
×
NEW
461
                changes[xmlUri] = [];
×
462
            }
NEW
463
            for (const edit of syntheticEdits) {
×
NEW
464
                changes[xmlUri].push({
×
465
                    ...edit,
466
                    range: {
467
                        start: cdataCtx.fromSynthetic(edit.range.start),
468
                        end: cdataCtx.fromSynthetic(edit.range.end)
469
                    }
470
                });
471
            }
472
        }
473
    }
474

475
    /**
476
     * Given an XmlFile and a cursor position, returns a `CdataContext` if the position falls
477
     * inside a `<![CDATA[...]]>` block, or `undefined` if it does not. Use the returned context
478
     * to redirect LSP events to the synthetic BrsFile and remap positions in both directions.
479
     */
480
    public resolveCdataContext(xmlFile: XmlFile, position: Position): CdataContext | undefined {
481
        const pointRange = util.createRange(position.line, position.character, position.line, position.character);
29✔
482
        const info = this.findCdataInfoForRange(xmlFile, pointRange);
29✔
483
        if (!info) {
29✔
484
            return undefined;
19✔
485
        }
486
        const cdataRange = info.meta.cdataRange;
10✔
487
        return {
10✔
488
            brsFile: info.brsFile,
489
            xmlFile: xmlFile,
490
            cdataRange: cdataRange,
491
            toSynthetic: (pos) => this.remapPosToSynthetic(cdataRange, pos),
9✔
492
            fromSynthetic: (pos) => this.remapPosFromSynthetic(cdataRange, pos)
22✔
493
        };
494
    }
495

496
    /**
497
     * Remaps any `Location` entries that reference the synthetic BrsFile back to the parent
498
     * XML file with positions converted to XML coordinates. Locations in other files are
499
     * returned unchanged.
500
     */
501
    private remapLocationsFromSynthetic(locations: Location[], cdataCtx: CdataContext): Location[] {
502
        const syntheticUri = URI.file(cdataCtx.brsFile.srcPath).toString();
2✔
503
        const xmlUri = URI.file(cdataCtx.xmlFile.srcPath).toString();
2✔
504
        return locations.map(loc => {
2✔
505
            if (loc.uri !== syntheticUri) {
2!
NEW
506
                return loc;
×
507
            }
508
            return {
2✔
509
                uri: xmlUri,
510
                range: {
511
                    start: cdataCtx.fromSynthetic(loc.range.start),
512
                    end: cdataCtx.fromSynthetic(loc.range.end)
513
                }
514
            };
515
        });
516
    }
517

518
    /**
519
     * Recursively remaps `range` and `selectionRange` in a `DocumentSymbol` tree from
520
     * synthetic BrsFile coordinates to parent XML file coordinates.
521
     */
522
    private remapDocumentSymbolsFromSynthetic(symbols: DocumentSymbol[], cdataCtx: CdataContext): DocumentSymbol[] {
523
        return symbols.map(sym => ({
3✔
524
            ...sym,
525
            range: {
526
                start: cdataCtx.fromSynthetic(sym.range.start),
527
                end: cdataCtx.fromSynthetic(sym.range.end)
528
            },
529
            selectionRange: {
530
                start: cdataCtx.fromSynthetic(sym.selectionRange.start),
531
                end: cdataCtx.fromSynthetic(sym.selectionRange.end)
532
            },
533
            children: sym.children ? this.remapDocumentSymbolsFromSynthetic(sym.children, cdataCtx) : undefined
2!
534
        }));
535
    }
536

537
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
538
        this.diagnostics.push(...diagnostics);
45✔
539
    }
540

541
    /**
542
     * Determine if the specified file is loaded in this program right now.
543
     * @param filePath the absolute or relative path to the file
544
     * @param normalizePath should the provided path be normalized before use
545
     */
546
    public hasFile(filePath: string, normalizePath = true) {
1,625✔
547
        return !!this.getFile(filePath, normalizePath);
1,625✔
548
    }
549

550
    public getPkgPath(...args: any[]): any { //eslint-disable-line
551
        throw new Error('Not implemented');
×
552
    }
553

554
    /**
555
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
556
     */
557
    public getScopeByName(scopeName: string): Scope | undefined {
558
        if (!scopeName) {
35!
559
            return undefined;
×
560
        }
561
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
562
        //so it's safe to run the standardizePkgPath method
563
        scopeName = s`${scopeName}`;
35✔
564
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
86✔
565
        return this.scopes[key!];
35✔
566
    }
567

568
    /**
569
     * Return all scopes
570
     */
571
    public getScopes() {
572
        return Object.values(this.scopes);
9✔
573
    }
574

575
    /**
576
     * Find the scope for the specified component
577
     */
578
    public getComponentScope(componentName: string) {
579
        return this.getComponent(componentName)?.scope;
201✔
580
    }
581

582
    /**
583
     * Update internal maps with this file reference
584
     */
585
    private assignFile<T extends BscFile = BscFile>(file: T) {
586
        this.files[file.srcPath.toLowerCase()] = file;
1,587✔
587
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,587✔
588
        return file;
1,587✔
589
    }
590

591
    /**
592
     * Remove this file from internal maps
593
     */
594
    private unassignFile<T extends BscFile = BscFile>(file: T) {
595
        delete this.files[file.srcPath.toLowerCase()];
184✔
596
        delete this.pkgMap[file.pkgPath.toLowerCase()];
184✔
597
        return file;
184✔
598
    }
599

600
    /**
601
     * Load a file into the program. If that file already exists, it is replaced.
602
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
603
     * @param srcPath the file path relative to the root dir
604
     * @param fileContents the file contents
605
     * @deprecated use `setFile` instead
606
     */
607
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
608
    /**
609
     * Load a file into the program. If that file already exists, it is replaced.
610
     * @param fileEntry an object that specifies src and dest for the file.
611
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
612
     * @deprecated use `setFile` instead
613
     */
614
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
615
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
616
        return this.setFile<T>(fileParam as any, fileContents);
1✔
617
    }
618

619
    /**
620
     * Load a file into the program. If that file already exists, it is replaced.
621
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
622
     * @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:/`)
623
     * @param fileContents the file contents
624
     */
625
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
626
    /**
627
     * Load a file into the program. If that file already exists, it is replaced.
628
     * @param fileEntry an object that specifies src and dest for the file.
629
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
630
     */
631
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
632
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
633
        //normalize the file paths
634
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,591✔
635

636
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,591✔
637
            //if the file is already loaded, remove it
638
            if (this.hasFile(srcPath)) {
1,591✔
639
                this.removeFile(srcPath);
139✔
640
            }
641
            let fileExtension = path.extname(srcPath).toLowerCase();
1,591✔
642
            let file: BscFile | undefined;
643

644
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,591✔
645
                //add the file to the program
646
                const brsFile = this.assignFile(
1,304✔
647
                    new BrsFile(srcPath, pkgPath, this)
648
                );
649

650
                //add file to the `source` dependency list
651
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,304✔
652
                    this.createSourceScope();
1,086✔
653
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,086✔
654
                }
655

656
                let sourceObj: SourceObj = {
1,304✔
657
                    //TODO remove `pathAbsolute` in v1
658
                    pathAbsolute: srcPath,
659
                    srcPath: srcPath,
660
                    source: fileContents
661
                };
662
                this.plugins.emit('beforeFileParse', sourceObj);
1,304✔
663

664
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,304✔
665
                    brsFile.parse(sourceObj.source);
1,304✔
666
                });
667

668
                //notify plugins that this file has finished parsing
669
                this.plugins.emit('afterFileParse', brsFile);
1,304✔
670

671
                file = brsFile;
1,304✔
672

673
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,304✔
674

675
            } else if (
287✔
676
                //is xml file
677
                fileExtension === '.xml' &&
573✔
678
                //resides in the components folder (Roku will only parse xml files in the components folder)
679
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
680
            ) {
681
                //add the file to the program
682
                const xmlFile = this.assignFile(
283✔
683
                    new XmlFile(srcPath, pkgPath, this)
684
                );
685

686
                let sourceObj: SourceObj = {
283✔
687
                    //TODO remove `pathAbsolute` in v1
688
                    pathAbsolute: srcPath,
689
                    srcPath: srcPath,
690
                    source: fileContents
691
                };
692
                this.plugins.emit('beforeFileParse', sourceObj);
283✔
693

694
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
283✔
695
                    xmlFile.parse(sourceObj.source);
283✔
696
                });
697

698
                //notify plugins that this file has finished parsing
699
                this.plugins.emit('afterFileParse', xmlFile);
283✔
700

701
                file = xmlFile;
283✔
702

703
                //register synthetic BrsFiles for any inline CDATA script blocks.
704
                //these are treated as first-class files so all plugins (linters, validators, etc.) see them normally.
705
                let cdataScriptIndex = 0;
283✔
706
                for (const script of xmlFile.ast.component?.scripts ?? []) {
283✔
707
                    if (script.cdata) {
212✔
708
                        const inlinePkgPath = xmlFile.inlineScriptPkgPaths[cdataScriptIndex++];
29✔
709
                        const inlineFile = this.setFile<BrsFile>(inlinePkgPath, script.cdataText ?? '');
29!
710
                        inlineFile.isSynthetic = true;
29✔
711
                        this.syntheticFileMeta.set(inlinePkgPath.toLowerCase(), {
29✔
712
                            xmlFile: xmlFile,
713
                            cdataRange: script.cdata.range
714
                        });
715
                    }
716
                }
717

718
                //create a new scope for this xml file
719
                let scope = new XmlScope(xmlFile, this);
283✔
720
                this.addScope(scope);
283✔
721

722
                //register this compoent now that we have parsed it and know its component name
723
                this.registerComponent(xmlFile, scope);
283✔
724

725
                //notify plugins that the scope is created and the component is registered
726
                this.plugins.emit('afterScopeCreate', scope);
283✔
727
            } else {
728
                //TODO do we actually need to implement this? Figure out how to handle img paths
729
                // let genericFile = this.files[srcPath] = <any>{
730
                //     srcPath: srcPath,
731
                //     pkgPath: pkgPath,
732
                //     wasProcessed: true
733
                // } as File;
734
                // file = <any>genericFile;
735
            }
736
            return file;
1,591✔
737
        });
738
        return file as T;
1,591✔
739
    }
740

741
    /**
742
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
743
     * @param fileParam an object representing file paths
744
     * @param rootDir must be a pre-normalized path
745
     */
746
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
747
        let srcPath: string | undefined;
748
        let pkgPath: string | undefined;
749

750
        assert.ok(fileParam, 'fileParam is required');
1,598✔
751

752
        //lift the srcPath and pkgPath vars from the incoming param
753
        if (typeof fileParam === 'string') {
1,598✔
754
            fileParam = this.removePkgPrefix(fileParam);
1,144✔
755
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,144✔
756
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,144✔
757
        } else {
758
            let param: any = fileParam;
454✔
759

760
            if (param.src) {
454✔
761
                srcPath = s`${param.src}`;
451✔
762
            }
763
            if (param.srcPath) {
454✔
764
                srcPath = s`${param.srcPath}`;
2✔
765
            }
766
            if (param.dest) {
454✔
767
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
451✔
768
            }
769
            if (param.pkgPath) {
454✔
770
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
771
            }
772
        }
773

774
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
775
        if (!srcPath) {
1,598✔
776
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
777
        }
778
        //coerce srcPath to an absolute path
779
        if (!path.isAbsolute(srcPath)) {
1,598✔
780
            srcPath = util.standardizePath(srcPath);
1✔
781
        }
782

783
        //if there's no pkgPath, compute relative path from rootDir
784
        if (!pkgPath) {
1,598✔
785
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
786
        }
787

788
        assert.ok(srcPath, 'fileEntry.src is required');
1,598✔
789
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,598✔
790

791
        return {
1,598✔
792
            srcPath: srcPath,
793
            //remove leading slash from pkgPath
794
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
795
        };
796
    }
797

798
    /**
799
     * Remove any leading `pkg:/` found in the path
800
     */
801
    private removePkgPrefix(path: string) {
802
        return path.replace(/^pkg:\//i, '');
1,597✔
803
    }
804

805
    /**
806
     * Ensure source scope is created.
807
     * Note: automatically called internally, and no-op if it exists already.
808
     */
809
    public createSourceScope() {
810
        if (!this.scopes.source) {
1,493✔
811
            const sourceScope = new Scope('source', this, 'scope:source');
1,108✔
812
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,108✔
813
            this.addScope(sourceScope);
1,108✔
814
            this.plugins.emit('afterScopeCreate', sourceScope);
1,108✔
815
        }
816
    }
817

818
    /**
819
     * Find the file by its absolute path. This is case INSENSITIVE, since
820
     * Roku is a case insensitive file system. It is an error to have multiple files
821
     * with the same path with only case being different.
822
     * @param srcPath the absolute path to the file
823
     * @deprecated use `getFile` instead, which auto-detects the path type
824
     */
825
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
826
        srcPath = s`${srcPath}`;
×
827
        for (let filePath in this.files) {
×
828
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
829
                return this.files[filePath] as T;
×
830
            }
831
        }
832
    }
833

834
    /**
835
     * Get a list of files for the given (platform-normalized) pkgPath array.
836
     * Missing files are just ignored.
837
     * @deprecated use `getFiles` instead, which auto-detects the path types
838
     */
839
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
840
        return pkgPaths
×
841
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
842
            .filter(file => file !== undefined) as T;
×
843
    }
844

845
    /**
846
     * Get a file with the specified (platform-normalized) pkg path.
847
     * If not found, return undefined
848
     * @deprecated use `getFile` instead, which auto-detects the path type
849
     */
850
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
851
        return this.pkgMap[pkgPath.toLowerCase()] as T;
507✔
852
    }
853

854
    /**
855
     * Remove a set of files from the program
856
     * @param srcPaths can be an array of srcPath or destPath strings
857
     * @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
858
     */
859
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
860
        for (let srcPath of srcPaths) {
1✔
861
            this.removeFile(srcPath, normalizePath);
1✔
862
        }
863
    }
864

865
    /**
866
     * Remove a file from the program
867
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
868
     * @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
869
     */
870
    public removeFile(filePath: string, normalizePath = true) {
180✔
871
        this.logger.debug('Program.removeFile()', filePath);
184✔
872

873
        let file = this.getFile(filePath, normalizePath);
184✔
874
        if (file) {
184!
875
            this.plugins.emit('beforeFileDispose', file);
184✔
876

877
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
878
            let scope = this.scopes[file.pkgPath];
184✔
879
            if (scope) {
184✔
880
                this.plugins.emit('beforeScopeDispose', scope);
14✔
881
                scope.dispose();
14✔
882
                //notify dependencies of this scope that it has been removed
883
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
14✔
884
                delete this.scopes[file.pkgPath];
14✔
885
                this.plugins.emit('afterScopeDispose', scope);
14✔
886
            }
887
            //remove the file from the program
888
            this.unassignFile(file);
184✔
889

890
            //clean up synthetic file metadata if this was a synthetic CDATA file
891
            if (isBrsFile(file) && file.isSynthetic) {
184✔
892
                this.syntheticFileMeta.delete(file.pkgPath.toLowerCase());
29✔
893
            }
894

895
            this.dependencyGraph.remove(file.dependencyGraphKey);
184✔
896

897
            //if this is a pkg:/source file, notify the `source` scope that it has changed
898
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
184✔
899
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
101✔
900
            }
901

902
            //if this is a component, remove it from our components map
903
            if (isXmlFile(file)) {
184✔
904
                this.unregisterComponent(file);
14✔
905
            }
906
            //dispose file
907
            file?.dispose();
184!
908
            this.plugins.emit('afterFileDispose', file);
184✔
909
        }
910
    }
911

912
    /**
913
     * Counter used to track which validation run is being logged
914
     */
915
    private validationRunSequence = 1;
1,514✔
916

917
    /**
918
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
919
     */
920
    private validationMinSyncDuration = 75;
1,514✔
921

922
    private validatePromise: Promise<void> | undefined;
923

924
    /**
925
     * Traverse the entire project, and validate all scopes
926
     */
927
    public validate(): void;
928
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
929
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
930
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
931
        const validationRunId = this.validationRunSequence++;
955✔
932
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
955!
933

934
        let previousValidationPromise = this.validatePromise;
955✔
935
        const deferred = new Deferred();
955✔
936

937
        if (options?.async) {
955✔
938
            //we're async, so create a new promise chain to resolve after this validation is done
939
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
940
                return deferred.promise;
159✔
941
            });
942

943
            //we are not async but there's a pending promise, then we cannot run this validation
944
        } else if (previousValidationPromise !== undefined) {
796!
945
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
946
        }
947

948
        if (options?.async) {
955✔
949
            //we're async, so create a new promise chain to resolve after this validation is done
950
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
159✔
951
                return deferred.promise;
159✔
952
            });
953

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

959
        const sequencer = new Sequencer({
955✔
960
            name: 'program.validate',
961
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,730✔
962
            minSyncDuration: this.validationMinSyncDuration
963
        });
964

965
        let beforeProgramValidateWasEmitted = false;
955✔
966

967
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
968
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
969
        sequencer
955✔
970
            .once(() => {
971
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
972
                if (options?.async) {
955✔
973
                    return previousValidationPromise;
159✔
974
                }
975
            })
976
            .once(() => {
977
                this.diagnostics = [];
953✔
978
                this.plugins.emit('beforeProgramValidate', this);
953✔
979
                beforeProgramValidateWasEmitted = true;
953✔
980
            })
981
            .forEach(() => Object.values(this.files), (file) => {
953✔
982
                if (!file.isValidated) {
1,239✔
983
                    this.plugins.emit('beforeFileValidate', {
1,172✔
984
                        program: this,
985
                        file: file
986
                    });
987

988
                    //emit an event to allow plugins to contribute to the file validation process
989
                    this.plugins.emit('onFileValidate', {
1,172✔
990
                        program: this,
991
                        file: file
992
                    });
993
                    //call file.validate() IF the file has that function defined
994
                    file.validate?.();
1,172!
995
                    file.isValidated = true;
1,171✔
996

997
                    this.plugins.emit('afterFileValidate', file);
1,171✔
998
                }
999
            })
1000
            .forEach(Object.values(this.scopes), (scope) => {
1001
                scope.linkSymbolTable();
1,976✔
1002
                scope.validate();
1,976✔
1003
                scope.unlinkSymbolTable();
1,974✔
1004
            })
1005
            .once(() => {
1006
                this.detectDuplicateComponentNames();
945✔
1007
            })
1008
            .onCancel(() => {
1009
                timeEnd('cancelled');
10✔
1010
            })
1011
            .onSuccess(() => {
1012
                timeEnd();
945✔
1013
            })
1014
            .onComplete(() => {
1015
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
1016
                if (beforeProgramValidateWasEmitted) {
955✔
1017
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
953✔
1018
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
953✔
1019
                }
1020

1021
                //regardless of the success of the validation, mark this run as complete
1022
                deferred.resolve();
955✔
1023
                //clear the validatePromise which means we're no longer running a validation
1024
                this.validatePromise = undefined;
955✔
1025
            });
1026

1027
        //run the sequencer in async mode if enabled
1028
        if (options?.async) {
955✔
1029
            return sequencer.run();
159✔
1030

1031
            //run the sequencer in sync mode
1032
        } else {
1033
            return sequencer.runSync();
796✔
1034
        }
1035
    }
1036

1037
    /**
1038
     * Flag all duplicate component names
1039
     */
1040
    private detectDuplicateComponentNames() {
1041
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
945✔
1042
            const file = this.files[filePath];
1,230✔
1043
            //if this is an XmlFile, and it has a valid `componentName` property
1044
            if (isXmlFile(file) && file.componentName?.text) {
1,230✔
1045
                let lowerName = file.componentName.text.toLowerCase();
247✔
1046
                if (!map[lowerName]) {
247✔
1047
                    map[lowerName] = [];
244✔
1048
                }
1049
                map[lowerName].push(file);
247✔
1050
            }
1051
            return map;
1,230✔
1052
        }, {});
1053

1054
        for (let name in componentsByName) {
945✔
1055
            const xmlFiles = componentsByName[name];
244✔
1056
            //add diagnostics for every duplicate component with this name
1057
            if (xmlFiles.length > 1) {
244✔
1058
                for (let xmlFile of xmlFiles) {
3✔
1059
                    const { componentName } = xmlFile;
6✔
1060
                    this.diagnostics.push({
6✔
1061
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1062
                        range: xmlFile.componentName.range,
1063
                        file: xmlFile,
1064
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1065
                            return {
6✔
1066
                                location: util.createLocation(
1067
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
1068
                                    x.componentName.range
1069
                                ),
1070
                                message: 'Also defined here'
1071
                            };
1072
                        })
1073
                    });
1074
                }
1075
            }
1076
        }
1077
    }
1078

1079
    /**
1080
     * Get the files for a list of filePaths
1081
     * @param filePaths can be an array of srcPath or a destPath strings
1082
     * @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
1083
     */
1084
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
26✔
1085
        return filePaths
26✔
1086
            .map(filePath => this.getFile(filePath, normalizePath))
34✔
1087
            .filter(file => file !== undefined) as T[];
34✔
1088
    }
1089

1090
    /**
1091
     * Get the file at the given path
1092
     * @param filePath can be a srcPath or a destPath
1093
     * @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
1094
     */
1095
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,461✔
1096
        if (typeof filePath !== 'string') {
8,304✔
1097
            return undefined;
1,718✔
1098
        } else if (path.isAbsolute(filePath)) {
6,586✔
1099
            return this.files[
3,448✔
1100
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,448✔
1101
            ] as T;
1102
        } else {
1103
            return this.pkgMap[
3,138✔
1104
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,138!
1105
            ] as T;
1106
        }
1107
    }
1108

1109
    /**
1110
     * Get a list of all scopes the file is loaded into
1111
     * @param file the file
1112
     */
1113
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1114

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

1117
        let result = [] as Scope[];
605✔
1118
        if (resolvedFile) {
605✔
1119
            for (let key in this.scopes) {
604✔
1120
                let scope = this.scopes[key];
1,261✔
1121

1122
                if (scope.hasFile(resolvedFile)) {
1,261✔
1123
                    result.push(scope);
615✔
1124
                }
1125
            }
1126
        }
1127
        return result;
605✔
1128
    }
1129

1130
    /**
1131
     * Get the first found scope for a file.
1132
     */
1133
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1134
        for (let key in this.scopes) {
2,772✔
1135
            let scope = this.scopes[key];
6,899✔
1136

1137
            if (scope.hasFile(file)) {
6,899✔
1138
                return scope;
2,631✔
1139
            }
1140
        }
1141
    }
1142

1143
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1144
        let results = new Map<Statement, FileLink<Statement>>();
40✔
1145
        const filesSearched = new Set<BrsFile>();
40✔
1146
        let lowerNamespaceName = namespaceName?.toLowerCase();
40✔
1147
        let lowerName = name?.toLowerCase();
40!
1148
        //look through all files in scope for matches
1149
        for (const scope of this.getScopesForFile(originFile)) {
40✔
1150
            for (const file of scope.getAllFiles()) {
40✔
1151
                if (isXmlFile(file) || filesSearched.has(file)) {
47✔
1152
                    continue;
4✔
1153
                }
1154
                filesSearched.add(file);
43✔
1155

1156
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
43✔
1157
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
100✔
1158
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
100✔
1159
                        if (!results.has(statement)) {
37!
1160
                            results.set(statement, { item: statement, file: file });
37✔
1161
                        }
1162
                    }
1163
                }
1164
            }
1165
        }
1166
        return [...results.values()];
40✔
1167
    }
1168

1169
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1170
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1171
        const filesSearched = new Set<BrsFile>();
8✔
1172

1173
        //get all function names for the xml file and parents
1174
        let funcNames = new Set<string>();
8✔
1175
        let currentScope = scope;
8✔
1176
        while (isXmlScope(currentScope)) {
8✔
1177
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1178
                if (!filterName || name === filterName) {
14!
1179
                    funcNames.add(name);
14✔
1180
                }
1181
            }
1182
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1183
        }
1184

1185
        //look through all files in scope for matches
1186
        for (const file of scope.getOwnFiles()) {
8✔
1187
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1188
                continue;
8✔
1189
            }
1190
            filesSearched.add(file);
8✔
1191

1192
            for (const statement of file.parser.references.functionStatements) {
8✔
1193
                if (funcNames.has(statement.name.text)) {
13!
1194
                    if (!results.has(statement)) {
13!
1195
                        results.set(statement, { item: statement, file: file });
13✔
1196
                    }
1197
                }
1198
            }
1199
        }
1200
        return [...results.values()];
8✔
1201
    }
1202

1203
    /**
1204
     * Find all available completion items at the given position
1205
     * @param filePath can be a srcPath or a destPath
1206
     * @param position the position (line & column) where completions should be found
1207
     */
1208
    public getCompletions(filePath: string, position: Position) {
1209
        let file = this.getFile(filePath);
79✔
1210
        if (!file) {
79!
1211
            return [];
×
1212
        }
1213

1214
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
79✔
1215
        const effectiveFile = cdataCtx?.brsFile ?? file;
79✔
1216
        const effectivePosition = cdataCtx ? cdataCtx.toSynthetic(position) : position;
79✔
1217

1218
        //find the scopes for this file
1219
        let scopes = this.getScopesForFile(effectiveFile);
79✔
1220

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

1224
        const event: ProvideCompletionsEvent = {
79✔
1225
            program: this,
1226
            file: effectiveFile,
1227
            scopes: scopes,
1228
            position: effectivePosition,
1229
            completions: []
1230
        };
1231

1232
        this.plugins.emit('beforeProvideCompletions', event);
79✔
1233
        this.plugins.emit('provideCompletions', event);
79✔
1234
        this.plugins.emit('afterProvideCompletions', event);
79✔
1235

1236
        if (cdataCtx) {
79✔
1237
            for (const completion of event.completions) {
1✔
1238
                if (completion.textEdit) {
155!
NEW
1239
                    if ('range' in completion.textEdit) {
×
NEW
1240
                        completion.textEdit = { ...completion.textEdit, range: { start: cdataCtx.fromSynthetic(completion.textEdit.range.start), end: cdataCtx.fromSynthetic(completion.textEdit.range.end) } };
×
1241
                    } else {
NEW
1242
                        completion.textEdit = { ...completion.textEdit, insert: { start: cdataCtx.fromSynthetic(completion.textEdit.insert.start), end: cdataCtx.fromSynthetic(completion.textEdit.insert.end) }, replace: { start: cdataCtx.fromSynthetic(completion.textEdit.replace.start), end: cdataCtx.fromSynthetic(completion.textEdit.replace.end) } };
×
1243
                    }
1244
                }
1245
                if (completion.additionalTextEdits) {
155!
NEW
1246
                    completion.additionalTextEdits = completion.additionalTextEdits.map(e => ({ ...e, range: { start: cdataCtx.fromSynthetic(e.range.start), end: cdataCtx.fromSynthetic(e.range.end) } }));
×
1247
                }
1248
            }
1249
        }
1250

1251
        return event.completions;
79✔
1252
    }
1253

1254
    /**
1255
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1256
     */
1257
    public getWorkspaceSymbols() {
1258
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1259
            program: this,
1260
            workspaceSymbols: []
1261
        };
1262
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1263
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1264
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1265
        return event.workspaceSymbols;
22✔
1266
    }
1267

1268
    /**
1269
     * Given a position in a file, if the position is sitting on some type of identifier,
1270
     * go to the definition of that identifier (where this thing was first defined)
1271
     */
1272
    public getDefinition(srcPath: string, position: Position): Location[] {
1273
        let file = this.getFile(srcPath);
20✔
1274
        if (!file) {
20!
1275
            return [];
×
1276
        }
1277

1278
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
20✔
1279
        const effectiveFile = cdataCtx?.brsFile ?? file;
20✔
1280
        const effectivePosition = cdataCtx ? cdataCtx.toSynthetic(position) : position;
20✔
1281

1282
        const event: ProvideDefinitionEvent = {
20✔
1283
            program: this,
1284
            file: effectiveFile,
1285
            position: effectivePosition,
1286
            definitions: []
1287
        };
1288

1289
        this.plugins.emit('beforeProvideDefinition', event);
20✔
1290
        this.plugins.emit('provideDefinition', event);
20✔
1291
        this.plugins.emit('afterProvideDefinition', event);
20✔
1292
        return cdataCtx ? this.remapLocationsFromSynthetic(event.definitions, cdataCtx) : event.definitions;
20✔
1293
    }
1294

1295
    /**
1296
     * Get hover information for a file and position
1297
     */
1298
    public getHover(srcPath: string, position: Position): Hover[] {
1299
        let file = this.getFile(srcPath);
33✔
1300
        let result: Hover[];
1301
        if (file) {
33!
1302
            const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
33✔
1303
            const effectiveFile = cdataCtx?.brsFile ?? file;
33✔
1304
            const effectivePosition = cdataCtx ? cdataCtx.toSynthetic(position) : position;
33✔
1305

1306
            const event = {
33✔
1307
                program: this,
1308
                file: effectiveFile,
1309
                position: effectivePosition,
1310
                scopes: this.getScopesForFile(effectiveFile),
1311
                hovers: []
1312
            } as ProvideHoverEvent;
1313
            this.plugins.emit('beforeProvideHover', event);
33✔
1314
            this.plugins.emit('provideHover', event);
33✔
1315
            this.plugins.emit('afterProvideHover', event);
33✔
1316

1317
            result = cdataCtx
33✔
1318
                ? event.hovers.map(h => (h.range ? { ...h, range: { start: cdataCtx.fromSynthetic(h.range.start), end: cdataCtx.fromSynthetic(h.range.end) } } : h))
3!
1319
                : event.hovers;
1320
        }
1321

1322
        return result ?? [];
33!
1323
    }
1324

1325
    /**
1326
     * Get full list of document symbols for a file
1327
     * @param srcPath path to the file
1328
     */
1329
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1330
        let file = this.getFile(srcPath);
25✔
1331
        if (!file) {
25!
UNCOV
1332
            return undefined;
×
1333
        }
1334
        const event: ProvideDocumentSymbolsEvent = {
25✔
1335
            program: this,
1336
            file: file,
1337
            documentSymbols: []
1338
        };
1339
        this.plugins.emit('beforeProvideDocumentSymbols', event);
25✔
1340
        this.plugins.emit('provideDocumentSymbols', event);
25✔
1341
        this.plugins.emit('afterProvideDocumentSymbols', event);
25✔
1342

1343
        // For XML files, also collect symbols from each inline CDATA block and remap
1344
        if (isXmlFile(file)) {
25✔
1345
            for (const [pkgPath, meta] of this.syntheticFileMeta) {
2✔
1346
                if (meta.xmlFile !== file) {
1!
NEW
1347
                    continue;
×
1348
                }
1349
                const brsFile = this.getFile<BrsFile>(pkgPath);
1✔
1350
                if (!brsFile) {
1!
NEW
1351
                    continue;
×
1352
                }
1353
                const cdataCtx = this.resolveCdataContext(file, meta.cdataRange.start);
1✔
1354
                if (!cdataCtx) {
1!
NEW
1355
                    continue;
×
1356
                }
1357
                const cdataEvent: ProvideDocumentSymbolsEvent = {
1✔
1358
                    program: this,
1359
                    file: brsFile,
1360
                    documentSymbols: []
1361
                };
1362
                this.plugins.emit('beforeProvideDocumentSymbols', cdataEvent);
1✔
1363
                this.plugins.emit('provideDocumentSymbols', cdataEvent);
1✔
1364
                this.plugins.emit('afterProvideDocumentSymbols', cdataEvent);
1✔
1365
                event.documentSymbols.push(...this.remapDocumentSymbolsFromSynthetic(cdataEvent.documentSymbols, cdataCtx));
1✔
1366
            }
1367
        }
1368

1369
        return event.documentSymbols;
25✔
1370
    }
1371

1372
    /**
1373
     * Compute code actions for the given file and range
1374
     */
1375
    public getCodeActions(srcPath: string, range: Range) {
1376
        const codeActions = [] as CodeAction[];
53✔
1377
        const file = this.getFile(srcPath);
53✔
1378
        if (file) {
53✔
1379
            // resolveCdataContext uses range.start as a probe point; findCdataInfoForRange
1380
            // is used internally to check intersection with the full range.
1381
            const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, range.start) : undefined;
52✔
1382

1383
            // When the range falls inside a CDATA block, redirect the event to the synthetic
1384
            // BrsFile so that BrsFile-specific code actions work correctly. Setting
1385
            // _cdataDiagnosticsContext causes getDiagnostics() to keep that file's diagnostics
1386
            // in synthetic-file coordinate space (rather than remapping to the parent XML file),
1387
            // so that plugins can match diagnostics by file identity (x.file === event.file)
1388
            // and derive correct edit positions from diagnostic.data (AST node ranges).
1389
            const effectiveFile = cdataCtx?.brsFile ?? file;
52✔
1390
            const effectiveRange = cdataCtx ? {
52✔
1391
                start: cdataCtx.toSynthetic(range.start),
1392
                end: cdataCtx.toSynthetic(range.end)
1393
            } : range;
1394

1395
            this._cdataDiagnosticsContext = cdataCtx?.brsFile;
52✔
1396

1397
            const diagnostics = this
52✔
1398
                //get all current diagnostics (filtered by diagnostic filters)
1399
                .getDiagnostics()
1400
                //only keep diagnostics related to this file
1401
                .filter(x => x.file === effectiveFile)
89✔
1402
                //only keep diagnostics that touch this range
1403
                .filter(x => util.rangesIntersectOrTouch(x.range, effectiveRange));
66✔
1404

1405
            const scopes = this.getScopesForFile(effectiveFile);
52✔
1406

1407
            this.plugins.emit('onGetCodeActions', {
52✔
1408
                program: this,
1409
                file: effectiveFile,
1410
                range: effectiveRange,
1411
                diagnostics: diagnostics,
1412
                scopes: scopes,
1413
                codeActions: codeActions
1414
            });
1415

1416
            this._cdataDiagnosticsContext = undefined;
52✔
1417

1418
            if (cdataCtx) {
52✔
1419
                this.remapCodeActionChangesToXml(codeActions, cdataCtx, cdataCtx.brsFile, file as XmlFile);
1✔
1420
            }
1421
        }
1422
        return codeActions;
53✔
1423
    }
1424

1425
    /**
1426
     * Get semantic tokens for the specified file
1427
     */
1428
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1429
        const file = this.getFile(srcPath);
17✔
1430
        if (!file) {
17!
NEW
1431
            return undefined;
×
1432
        }
1433
        const result = [] as SemanticToken[];
17✔
1434
        this.plugins.emit('onGetSemanticTokens', {
17✔
1435
            program: this,
1436
            file: file,
1437
            scopes: this.getScopesForFile(file),
1438
            semanticTokens: result
1439
        });
1440

1441
        // For XML files, also collect semantic tokens from each inline CDATA block and remap
1442
        if (isXmlFile(file)) {
17✔
1443
            for (const [pkgPath, meta] of this.syntheticFileMeta) {
1✔
1444
                if (meta.xmlFile !== file) {
2✔
1445
                    continue;
1✔
1446
                }
1447
                const brsFile = this.getFile<BrsFile>(pkgPath);
1✔
1448
                if (!brsFile) {
1!
NEW
1449
                    continue;
×
1450
                }
1451
                const cdataCtx = this.resolveCdataContext(file, meta.cdataRange.start);
1✔
1452
                if (!cdataCtx) {
1!
NEW
1453
                    continue;
×
1454
                }
1455
                const cdataTokens = [] as SemanticToken[];
1✔
1456
                this.plugins.emit('onGetSemanticTokens', {
1✔
1457
                    program: this,
1458
                    file: brsFile,
1459
                    scopes: this.getScopesForFile(brsFile),
1460
                    semanticTokens: cdataTokens
1461
                });
1462
                for (const token of cdataTokens) {
1✔
1463
                    result.push({
2✔
1464
                        ...token,
1465
                        range: {
1466
                            start: cdataCtx.fromSynthetic(token.range.start),
1467
                            end: cdataCtx.fromSynthetic(token.range.end)
1468
                        }
1469
                    });
1470
                }
1471
            }
1472
        }
1473

1474
        return result;
17✔
1475
    }
1476

1477
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1478
        let file = this.getFile(filePath);
189✔
1479
        if (!file) {
189✔
1480
            return [];
2✔
1481
        }
1482
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
187✔
1483
        const effectiveFile = cdataCtx?.brsFile ?? file;
187✔
1484
        const effectivePosition = cdataCtx ? cdataCtx.toSynthetic(position) : position;
187✔
1485
        if (!isBrsFile(effectiveFile)) {
187✔
1486
            return [];
1✔
1487
        }
1488
        let callExpressionInfo = new CallExpressionInfo(effectiveFile, effectivePosition);
186✔
1489
        let signatureHelpUtil = new SignatureHelpUtil();
186✔
1490
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
186✔
1491
    }
1492

1493
    public getReferences(srcPath: string, position: Position): Location[] {
1494
        //find the file
1495
        let file = this.getFile(srcPath);
5✔
1496
        if (!file) {
5!
1497
            return null;
×
1498
        }
1499

1500
        const cdataCtx = isXmlFile(file) ? this.resolveCdataContext(file, position) : undefined;
5✔
1501
        const effectiveFile = cdataCtx?.brsFile ?? file;
5✔
1502
        const effectivePosition = cdataCtx ? cdataCtx.toSynthetic(position) : position;
5✔
1503

1504
        const event: ProvideReferencesEvent = {
5✔
1505
            program: this,
1506
            file: effectiveFile,
1507
            position: effectivePosition,
1508
            references: []
1509
        };
1510

1511
        this.plugins.emit('beforeProvideReferences', event);
5✔
1512
        this.plugins.emit('provideReferences', event);
5✔
1513
        this.plugins.emit('afterProvideReferences', event);
5✔
1514

1515
        return cdataCtx ? this.remapLocationsFromSynthetic(event.references, cdataCtx) : event.references;
5✔
1516
    }
1517

1518
    /**
1519
     * Get a list of all script imports, relative to the specified pkgPath
1520
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1521
     */
1522
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1523
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1524

1525
        let result = [] as CompletionItem[];
3✔
1526
        /**
1527
         * hashtable to prevent duplicate results
1528
         */
1529
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1530

1531
        //restrict to only .brs files
1532
        for (let key in this.files) {
3✔
1533
            let file = this.files[key];
4✔
1534
            if (
4✔
1535
                //is a BrightScript or BrighterScript file
1536
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1537
                //this file is not the current file
1538
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1539
            ) {
1540
                //add the relative path
1541
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1542
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1543
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1544
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1545
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1546
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1547

1548
                    result.push({
3✔
1549
                        label: relativePath,
1550
                        detail: file.srcPath,
1551
                        kind: CompletionItemKind.File,
1552
                        textEdit: {
1553
                            newText: relativePath,
1554
                            range: scriptImport.filePathRange
1555
                        }
1556
                    });
1557

1558
                    //add the absolute path
1559
                    result.push({
3✔
1560
                        label: filePkgPath,
1561
                        detail: file.srcPath,
1562
                        kind: CompletionItemKind.File,
1563
                        textEdit: {
1564
                            newText: filePkgPath,
1565
                            range: scriptImport.filePathRange
1566
                        }
1567
                    });
1568
                }
1569
            }
1570
        }
1571
        return result;
3✔
1572
    }
1573

1574
    /**
1575
     * Transpile a single file and get the result as a string.
1576
     * This does not write anything to the file system.
1577
     *
1578
     * This should only be called by `LanguageServer`.
1579
     * Internal usage should call `_getTranspiledFileContents` instead.
1580
     * @param filePath can be a srcPath or a destPath
1581
     */
1582
    public async getTranspiledFileContents(filePath: string) {
1583
        const file = this.getFile(filePath);
4✔
1584
        const fileMap: FileObj[] = [{
4✔
1585
            src: file.srcPath,
1586
            dest: file.pkgPath
1587
        }];
1588
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1589
        const result = this._getTranspiledFileContents(
4✔
1590
            file
1591
        );
1592
        this.afterProgramTranspile(entries, astEditor);
4✔
1593
        return Promise.resolve(result);
4✔
1594
    }
1595

1596
    /**
1597
     * Internal function used to transpile files.
1598
     * This does not write anything to the file system
1599
     */
1600
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1601
        const editor = new AstEditor();
328✔
1602
        this.plugins.emit('beforeFileTranspile', {
328✔
1603
            program: this,
1604
            file: file,
1605
            outputPath: outputPath,
1606
            editor: editor
1607
        });
1608

1609
        //if we have any edits, assume the file needs to be transpiled
1610
        if (editor.hasChanges) {
328✔
1611
            //use the `editor` because it'll track the previous value for us and revert later on
1612
            editor.setProperty(file, 'needsTranspiled', true);
77✔
1613
        }
1614

1615
        //transpile the file
1616
        const result = file.transpile();
328✔
1617

1618
        //generate the typedef if enabled
1619
        let typedef: string;
1620
        if (isBrsFile(file) && this.options.emitDefinitions) {
328✔
1621
            typedef = file.getTypedef();
2✔
1622
        }
1623

1624
        const event: AfterFileTranspileEvent = {
328✔
1625
            program: this,
1626
            file: file,
1627
            outputPath: outputPath,
1628
            editor: editor,
1629
            code: result.code,
1630
            map: result.map,
1631
            typedef: typedef
1632
        };
1633
        this.plugins.emit('afterFileTranspile', event);
328✔
1634

1635
        //undo all `editor` edits that may have been applied to this file.
1636
        editor.undoAll();
328✔
1637

1638
        return {
328✔
1639
            srcPath: file.srcPath,
1640
            pkgPath: file.pkgPath,
1641
            code: event.code,
1642
            map: event.map,
1643
            typedef: event.typedef
1644
        };
1645
    }
1646

1647
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1648
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1649
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1650
            collection[s`${entry.src}`] = entry;
20✔
1651
            return collection;
20✔
1652
        }, {});
1653

1654
        const getOutputPath = (file: BscFile) => {
37✔
1655
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1656
            if (!filePathObj) {
77✔
1657
                //this file has been added in-memory, from a plugin, for example
1658
                filePathObj = {
47✔
1659
                    //add an interpolated src path (since it doesn't actually exist in memory)
1660
                    src: `bsc:/${file.pkgPath}`,
1661
                    dest: file.pkgPath
1662
                };
1663
            }
1664
            //replace the file extension
1665
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1666
            //prepend the staging folder path
1667
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1668
            return outputPath;
77✔
1669
        };
1670

1671
        const entries = Object.values(this.files)
37✔
1672
            //only include the files from fileEntries
1673
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1674
            .map(file => {
1675
                return {
18✔
1676
                    file: file,
1677
                    outputPath: getOutputPath(file)
1678
                };
1679
            })
1680
            //sort the entries to make transpiling more deterministic
1681
            .sort((a, b) => {
1682
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1683
            });
1684

1685
        const astEditor = new AstEditor();
37✔
1686

1687
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1688
        return {
37✔
1689
            entries: entries,
1690
            getOutputPath: getOutputPath,
1691
            astEditor: astEditor
1692
        };
1693
    }
1694

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

1698
        const processedFiles = new Set<string>();
32✔
1699

1700
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1701
            //find the file in the program
1702
            const file = this.getFile(srcPath);
35✔
1703
            //mark this file as processed so we don't process it more than once
1704
            processedFiles.add(outputPath?.toLowerCase());
35!
1705

1706
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1707
                //skip transpiling typedef files
1708
                if (isBrsFile(file) && file.isTypedef) {
34✔
1709
                    return;
1✔
1710
                }
1711

1712
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1713

1714
                //make sure the full dir path exists
1715
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1716

1717
                if (await fsExtra.pathExists(outputPath)) {
33!
1718
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1719
                }
1720
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1721
                await Promise.all([
33✔
1722
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1723
                    writeMapPromise
1724
                ]);
1725

1726
                if (fileTranspileResult.typedef) {
33✔
1727
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1728
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1729
                }
1730
            }
1731
        };
1732

1733
        let promises = entries.map(async (entry) => {
32✔
1734
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1735
        });
1736

1737
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1738
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1739
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1740
        }
1741
        await Promise.all(promises);
32✔
1742

1743
        //transpile any new files that plugins added since the start of this transpile process
1744
        do {
32✔
1745
            promises = [];
52✔
1746
            for (const key in this.files) {
52✔
1747
                const file = this.files[key];
59✔
1748
                //this is a new file
1749
                const outputPath = getOutputPath(file);
59✔
1750
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1751
                    promises.push(
23✔
1752
                        transpileFile(file?.srcPath, outputPath)
69!
1753
                    );
1754
                }
1755
            }
1756
            if (promises.length > 0) {
52✔
1757
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1758
                await Promise.all(promises);
20✔
1759
            }
1760
        }
1761
        while (promises.length > 0);
1762
        this.afterProgramTranspile(entries, astEditor);
32✔
1763
    }
1764

1765
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1766
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1767
        astEditor.undoAll();
36✔
1768
    }
1769

1770
    /**
1771
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1772
     */
1773
    public findFilesForFunction(functionName: string) {
1774
        const files = [] as BscFile[];
33✔
1775
        const lowerFunctionName = functionName.toLowerCase();
33✔
1776
        //find every file with this function defined
1777
        for (const file of Object.values(this.files)) {
33✔
1778
            if (isBrsFile(file)) {
123✔
1779
                //TODO handle namespace-relative function calls
1780
                //if the file has a function with this name
1781
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
88✔
1782
                    files.push(file);
24✔
1783
                }
1784
            }
1785
        }
1786
        return files;
33✔
1787
    }
1788

1789
    /**
1790
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1791
     */
1792
    public findFilesForClass(className: string) {
1793
        const files = [] as BscFile[];
33✔
1794
        const lowerClassName = className.toLowerCase();
33✔
1795
        //find every file with this class defined
1796
        for (const file of Object.values(this.files)) {
33✔
1797
            if (isBrsFile(file)) {
123✔
1798
                //TODO handle namespace-relative classes
1799
                //if the file has a function with this name
1800
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
88✔
1801
                    files.push(file);
3✔
1802
                }
1803
            }
1804
        }
1805
        return files;
33✔
1806
    }
1807

1808
    public findFilesForNamespace(name: string) {
1809
        const files = [] as BscFile[];
33✔
1810
        const lowerName = name.toLowerCase();
33✔
1811
        //find every file with this class defined
1812
        for (const file of Object.values(this.files)) {
33✔
1813
            if (isBrsFile(file)) {
123✔
1814
                if (file.parser.references.namespaceStatements.find((x) => {
88✔
1815
                    const namespaceName = x.name.toLowerCase();
14✔
1816
                    return (
14✔
1817
                        //the namespace name matches exactly
1818
                        namespaceName === lowerName ||
18✔
1819
                        //the full namespace starts with the name (honoring the part boundary)
1820
                        namespaceName.startsWith(lowerName + '.')
1821
                    );
1822
                })) {
1823
                    files.push(file);
12✔
1824
                }
1825
            }
1826
        }
1827
        return files;
33✔
1828
    }
1829

1830
    public findFilesForEnum(name: string) {
1831
        const files = [] as BscFile[];
34✔
1832
        const lowerName = name.toLowerCase();
34✔
1833
        //find every file with this class defined
1834
        for (const file of Object.values(this.files)) {
34✔
1835
            if (isBrsFile(file)) {
124✔
1836
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
89✔
1837
                    files.push(file);
1✔
1838
                }
1839
            }
1840
        }
1841
        return files;
34✔
1842
    }
1843

1844
    private _manifest: Map<string, string>;
1845

1846
    /**
1847
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1848
     * @param parsedManifest The manifest map to read from and modify
1849
     */
1850
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1851
        // Lift the bs_consts defined in the manifest
1852
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1853

1854
        // Override or delete any bs_consts defined in the bs config
1855
        for (const key in this.options?.manifest?.bs_const) {
19!
1856
            const value = this.options.manifest.bs_const[key];
3✔
1857
            if (value === null) {
3✔
1858
                bsConsts.delete(key);
1✔
1859
            } else {
1860
                bsConsts.set(key, value);
2✔
1861
            }
1862
        }
1863

1864
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1865
        let constString = '';
19✔
1866
        for (const [key, value] of bsConsts) {
19✔
1867
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1868
        }
1869

1870
        // Set the updated bs_const value
1871
        parsedManifest.set('bs_const', constString);
19✔
1872
    }
1873

1874
    /**
1875
     * Try to find and load the manifest into memory
1876
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1877
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1878
     */
1879
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,044✔
1880
        //if we already have a manifest instance, and should not replace...then don't replace
1881
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,056!
1882
            return;
×
1883
        }
1884
        let manifestPath = manifestFileObj
1,056✔
1885
            ? manifestFileObj.src
1,056✔
1886
            : path.join(this.options.rootDir, 'manifest');
1887

1888
        try {
1,056✔
1889
            // we only load this manifest once, so do it sync to improve speed downstream
1890
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,056✔
1891
            const parsedManifest = parseManifest(contents);
19✔
1892
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1893
            this._manifest = parsedManifest;
19✔
1894
        } catch (e) {
1895
            this._manifest = new Map();
1,037✔
1896
        }
1897
    }
1898

1899
    /**
1900
     * Get a map of the manifest information
1901
     */
1902
    public getManifest() {
1903
        if (!this._manifest) {
1,388✔
1904
            this.loadManifest();
1,043✔
1905
        }
1906
        return this._manifest;
1,388✔
1907
    }
1908

1909
    public dispose() {
1910
        this.plugins.emit('beforeProgramDispose', { program: this });
1,346✔
1911

1912
        for (let filePath in this.files) {
1,346✔
1913
            this.files[filePath].dispose();
1,353✔
1914
        }
1915
        for (let name in this.scopes) {
1,346✔
1916
            this.scopes[name].dispose();
2,671✔
1917
        }
1918
        this.globalScope.dispose();
1,346✔
1919
        this.dependencyGraph.dispose();
1,346✔
1920
    }
1921
}
1922

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