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

rokucommunity / brighterscript / #15445

25 Mar 2026 07:04PM UTC coverage: 88.624% (-0.4%) from 88.992%
#15445

push

web-flow
Merge f83491a8a into adf045c2c

7960 of 9472 branches covered (84.04%)

Branch coverage included in aggregate %.

18 of 64 new or added lines in 9 files covered. (28.13%)

1 existing line in 1 file now uncovered.

10215 of 11036 relevant lines covered (92.56%)

1950.39 hits per line

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

92.7
/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 } 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, OnGetSourceFixAllCodeActionsEvent } from './interfaces';
12
import type { SourceFixAllCodeAction } from './CodeActionUtil';
13
import { codeActionUtil } from './CodeActionUtil';
1✔
14
import { standardizePath as s, util } from './util';
1✔
15
import { XmlScope } from './XmlScope';
1✔
16
import { DiagnosticFilterer } from './DiagnosticFilterer';
1✔
17
import { DependencyGraph } from './DependencyGraph';
1✔
18
import type { Logger } from './logging';
19
import { LogLevel, createLogger } from './logging';
1✔
20
import chalk from 'chalk';
1✔
21
import { globalFile } from './globalCallables';
1✔
22
import { parseManifest, getBsConst } from './preprocessor/Manifest';
1✔
23
import { URI } from 'vscode-uri';
1✔
24
import PluginInterface from './PluginInterface';
1✔
25
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
1✔
26
import type { FunctionStatement, NamespaceStatement } from './parser/Statement';
27
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
28
import { AstEditor } from './astUtils/AstEditor';
1✔
29
import type { SourceMapGenerator } from 'source-map';
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
export class Program {
1✔
63
    constructor(
64
        /**
65
         * The root directory for this program
66
         */
67
        options: BsConfig,
68
        logger?: Logger,
69
        plugins?: PluginInterface
70
    ) {
71
        this.options = util.normalizeConfig(options);
1,448✔
72
        this.logger = logger ?? createLogger(options);
1,448✔
73
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,448✔
74

75
        //inject the bsc plugin as the first plugin in the stack.
76
        this.plugins.addFirst(new BscPlugin());
1,448✔
77

78
        //normalize the root dir path
79
        this.options.rootDir = util.getRootDir(this.options);
1,448✔
80

81
        this.createGlobalScope();
1,448✔
82
    }
83

84
    public options: FinalizedBsConfig;
85
    public logger: Logger;
86

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

101
    /**
102
     * A graph of all files and their dependencies.
103
     * For example:
104
     *      File.xml -> [lib1.brs, lib2.brs]
105
     *      lib2.brs -> [lib3.brs] //via an import statement
106
     */
107
    private dependencyGraph = new DependencyGraph();
1,448✔
108

109
    private diagnosticFilterer = new DiagnosticFilterer();
1,448✔
110

111
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,448✔
112

113
    /**
114
     * A scope that contains all built-in global functions.
115
     * All scopes should directly or indirectly inherit from this scope
116
     */
117
    public globalScope: Scope = undefined as any;
1,448✔
118

119
    /**
120
     * Plugins which can provide extra diagnostics or transform AST
121
     */
122
    public plugins: PluginInterface;
123

124
    /**
125
     * A set of diagnostics. This does not include any of the scope diagnostics.
126
     * Should only be set from `this.validate()`
127
     */
128
    private diagnostics = [] as BsDiagnostic[];
1,448✔
129

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

138
            //if there's a non-aliased version of bslib from roku_modules, use that
139
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
453✔
140
            return bslibNonAliasedRokuModulesPkgPath;
3✔
141

142
            //default to the embedded version
143
        } else {
144
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
450✔
145
        }
146
    }
147

148
    public get bslibPrefix() {
149
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
428✔
150
            return 'rokucommunity_bslib';
3✔
151
        } else {
152
            return 'bslib';
425✔
153
        }
154
    }
155

156

157
    /**
158
     * A map of every file loaded into this program, indexed by its original file location
159
     */
160
    public files = {} as Record<string, BscFile>;
1,448✔
161
    private pkgMap = {} as Record<string, BscFile>;
1,448✔
162

163
    private scopes = {} as Record<string, Scope>;
1,448✔
164

165
    protected addScope(scope: Scope) {
166
        this.scopes[scope.name] = scope;
1,313✔
167
    }
168

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

177
    /**
178
     * Get the component with the specified name
179
     */
180
    public getComponent(componentName: string) {
181
        if (componentName) {
562✔
182
            //return the first compoment in the list with this name
183
            //(components are ordered in this list by pkgPath to ensure consistency)
184
            return this.components[componentName.toLowerCase()]?.[0];
524✔
185
        } else {
186
            return undefined;
38✔
187
        }
188
    }
189

190
    /**
191
     * Register (or replace) the reference to a component in the component map
192
     */
193
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
194
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
231✔
195
        if (!this.components[key]) {
231✔
196
            this.components[key] = [];
219✔
197
        }
198
        this.components[key].push({
231✔
199
            file: xmlFile,
200
            scope: scope
201
        });
202
        this.components[key].sort((a, b) => {
231✔
203
            const pathA = a.file.pkgPath.toLowerCase();
5✔
204
            const pathB = b.file.pkgPath.toLowerCase();
5✔
205
            if (pathA < pathB) {
5✔
206
                return -1;
1✔
207
            } else if (pathA > pathB) {
4!
208
                return 1;
4✔
209
            }
210
            return 0;
×
211
        });
212
        this.syncComponentDependencyGraph(this.components[key]);
231✔
213
    }
214

215
    /**
216
     * Remove the specified component from the components map
217
     */
218
    private unregisterComponent(xmlFile: XmlFile) {
219
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
11✔
220
        const arr = this.components[key] || [];
11!
221
        for (let i = 0; i < arr.length; i++) {
11✔
222
            if (arr[i].file === xmlFile) {
11!
223
                arr.splice(i, 1);
11✔
224
                break;
11✔
225
            }
226
        }
227
        this.syncComponentDependencyGraph(arr);
11✔
228
    }
229

230
    /**
231
     * re-attach the dependency graph with a new key for any component who changed
232
     * their position in their own named array (only matters when there are multiple
233
     * components with the same name)
234
     */
235
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
236
        //reattach every dependency graph
237
        for (let i = 0; i < components.length; i++) {
242✔
238
            const { file, scope } = components[i];
237✔
239

240
            //attach (or re-attach) the dependencyGraph for every component whose position changed
241
            if (file.dependencyGraphIndex !== i) {
237✔
242
                file.dependencyGraphIndex = i;
233✔
243
                file.attachDependencyGraph(this.dependencyGraph);
233✔
244
                scope.attachDependencyGraph(this.dependencyGraph);
233✔
245
            }
246
        }
247
    }
248

249
    /**
250
     * Get a list of all files that are included in the project but are not referenced
251
     * by any scope in the program.
252
     */
253
    public getUnreferencedFiles() {
254
        let result = [] as File[];
928✔
255
        for (let filePath in this.files) {
928✔
256
            let file = this.files[filePath];
1,096✔
257
            //is this file part of a scope
258
            if (!this.getFirstScopeForFile(file)) {
1,096✔
259
                //no scopes reference this file. add it to the list
260
                result.push(file);
43✔
261
            }
262
        }
263
        return result;
928✔
264
    }
265

266
    /**
267
     * Get the list of errors for the entire program. It's calculated on the fly
268
     * by walking through every file, so call this sparingly.
269
     */
270
    public getDiagnostics() {
271
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
928✔
272

273
            let diagnostics = [...this.diagnostics];
928✔
274

275
            //get the diagnostics from all scopes
276
            for (let scopeName in this.scopes) {
928✔
277
                let scope = this.scopes[scopeName];
1,870✔
278
                diagnostics.push(
1,870✔
279
                    ...scope.getDiagnostics()
280
                );
281
            }
282

283
            //get the diagnostics from all unreferenced files
284
            let unreferencedFiles = this.getUnreferencedFiles();
928✔
285
            for (let file of unreferencedFiles) {
928✔
286
                diagnostics.push(
43✔
287
                    ...file.getDiagnostics()
288
                );
289
            }
290
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
928✔
291
                //filter out diagnostics based on our diagnostic filters
292
                let finalDiagnostics = this.diagnosticFilterer.filter({
928✔
293
                    ...this.options,
294
                    rootDir: this.options.rootDir
295
                }, diagnostics);
296
                return finalDiagnostics;
928✔
297
            });
298

299
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
928✔
300
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
928✔
301
            });
302

303
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
928✔
304
            return filteredDiagnostics;
928✔
305
        });
306
    }
307

308
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
309
        this.diagnostics.push(...diagnostics);
45✔
310
    }
311

312
    /**
313
     * Determine if the specified file is loaded in this program right now.
314
     * @param filePath the absolute or relative path to the file
315
     * @param normalizePath should the provided path be normalized before use
316
     */
317
    public hasFile(filePath: string, normalizePath = true) {
1,497✔
318
        return !!this.getFile(filePath, normalizePath);
1,497✔
319
    }
320

321
    public getPkgPath(...args: any[]): any { //eslint-disable-line
322
        throw new Error('Not implemented');
×
323
    }
324

325
    /**
326
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
327
     */
328
    public getScopeByName(scopeName: string): Scope | undefined {
329
        if (!scopeName) {
35!
330
            return undefined;
×
331
        }
332
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
333
        //so it's safe to run the standardizePkgPath method
334
        scopeName = s`${scopeName}`;
35✔
335
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
86✔
336
        return this.scopes[key!];
35✔
337
    }
338

339
    /**
340
     * Return all scopes
341
     */
342
    public getScopes() {
343
        return Object.values(this.scopes);
9✔
344
    }
345

346
    /**
347
     * Find the scope for the specified component
348
     */
349
    public getComponentScope(componentName: string) {
350
        return this.getComponent(componentName)?.scope;
164✔
351
    }
352

353
    /**
354
     * Update internal maps with this file reference
355
     */
356
    private assignFile<T extends BscFile = BscFile>(file: T) {
357
        this.files[file.srcPath.toLowerCase()] = file;
1,459✔
358
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,459✔
359
        return file;
1,459✔
360
    }
361

362
    /**
363
     * Remove this file from internal maps
364
     */
365
    private unassignFile<T extends BscFile = BscFile>(file: T) {
366
        delete this.files[file.srcPath.toLowerCase()];
152✔
367
        delete this.pkgMap[file.pkgPath.toLowerCase()];
152✔
368
        return file;
152✔
369
    }
370

371
    /**
372
     * Load a file into the program. If that file already exists, it is replaced.
373
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
374
     * @param srcPath the file path relative to the root dir
375
     * @param fileContents the file contents
376
     * @deprecated use `setFile` instead
377
     */
378
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
379
    /**
380
     * Load a file into the program. If that file already exists, it is replaced.
381
     * @param fileEntry an object that specifies src and dest for the file.
382
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
383
     * @deprecated use `setFile` instead
384
     */
385
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
386
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
387
        return this.setFile<T>(fileParam as any, fileContents);
1✔
388
    }
389

390
    /**
391
     * Load a file into the program. If that file already exists, it is replaced.
392
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
393
     * @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:/`)
394
     * @param fileContents the file contents
395
     */
396
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
397
    /**
398
     * Load a file into the program. If that file already exists, it is replaced.
399
     * @param fileEntry an object that specifies src and dest for the file.
400
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
401
     */
402
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
403
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
404
        //normalize the file paths
405
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,463✔
406

407
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,463✔
408
            //if the file is already loaded, remove it
409
            if (this.hasFile(srcPath)) {
1,463✔
410
                this.removeFile(srcPath);
137✔
411
            }
412
            let fileExtension = path.extname(srcPath).toLowerCase();
1,463✔
413
            let file: BscFile | undefined;
414

415
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,463✔
416
                //add the file to the program
417
                const brsFile = this.assignFile(
1,228✔
418
                    new BrsFile(srcPath, pkgPath, this)
419
                );
420

421
                //add file to the `source` dependency list
422
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,228✔
423
                    this.createSourceScope();
1,055✔
424
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,055✔
425
                }
426

427
                let sourceObj: SourceObj = {
1,228✔
428
                    //TODO remove `pathAbsolute` in v1
429
                    pathAbsolute: srcPath,
430
                    srcPath: srcPath,
431
                    source: fileContents
432
                };
433
                this.plugins.emit('beforeFileParse', sourceObj);
1,228✔
434

435
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,228✔
436
                    brsFile.parse(sourceObj.source);
1,228✔
437
                });
438

439
                //notify plugins that this file has finished parsing
440
                this.plugins.emit('afterFileParse', brsFile);
1,228✔
441

442
                file = brsFile;
1,228✔
443

444
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,228✔
445

446
            } else if (
235✔
447
                //is xml file
448
                fileExtension === '.xml' &&
469✔
449
                //resides in the components folder (Roku will only parse xml files in the components folder)
450
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
451
            ) {
452
                //add the file to the program
453
                const xmlFile = this.assignFile(
231✔
454
                    new XmlFile(srcPath, pkgPath, this)
455
                );
456

457
                let sourceObj: SourceObj = {
231✔
458
                    //TODO remove `pathAbsolute` in v1
459
                    pathAbsolute: srcPath,
460
                    srcPath: srcPath,
461
                    source: fileContents
462
                };
463
                this.plugins.emit('beforeFileParse', sourceObj);
231✔
464

465
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
231✔
466
                    xmlFile.parse(sourceObj.source);
231✔
467
                });
468

469
                //notify plugins that this file has finished parsing
470
                this.plugins.emit('afterFileParse', xmlFile);
231✔
471

472
                file = xmlFile;
231✔
473

474
                //create a new scope for this xml file
475
                let scope = new XmlScope(xmlFile, this);
231✔
476
                this.addScope(scope);
231✔
477

478
                //register this compoent now that we have parsed it and know its component name
479
                this.registerComponent(xmlFile, scope);
231✔
480

481
                //notify plugins that the scope is created and the component is registered
482
                this.plugins.emit('afterScopeCreate', scope);
231✔
483
            } else {
484
                //TODO do we actually need to implement this? Figure out how to handle img paths
485
                // let genericFile = this.files[srcPath] = <any>{
486
                //     srcPath: srcPath,
487
                //     pkgPath: pkgPath,
488
                //     wasProcessed: true
489
                // } as File;
490
                // file = <any>genericFile;
491
            }
492
            return file;
1,463✔
493
        });
494
        return file as T;
1,463✔
495
    }
496

497
    /**
498
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
499
     * @param fileParam an object representing file paths
500
     * @param rootDir must be a pre-normalized path
501
     */
502
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
503
        let srcPath: string | undefined;
504
        let pkgPath: string | undefined;
505

506
        assert.ok(fileParam, 'fileParam is required');
1,470✔
507

508
        //lift the srcPath and pkgPath vars from the incoming param
509
        if (typeof fileParam === 'string') {
1,470✔
510
            fileParam = this.removePkgPrefix(fileParam);
1,018✔
511
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,018✔
512
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,018✔
513
        } else {
514
            let param: any = fileParam;
452✔
515

516
            if (param.src) {
452✔
517
                srcPath = s`${param.src}`;
449✔
518
            }
519
            if (param.srcPath) {
452✔
520
                srcPath = s`${param.srcPath}`;
2✔
521
            }
522
            if (param.dest) {
452✔
523
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
449✔
524
            }
525
            if (param.pkgPath) {
452✔
526
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
527
            }
528
        }
529

530
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
531
        if (!srcPath) {
1,470✔
532
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
533
        }
534
        //coerce srcPath to an absolute path
535
        if (!path.isAbsolute(srcPath)) {
1,470✔
536
            srcPath = util.standardizePath(srcPath);
1✔
537
        }
538

539
        //if there's no pkgPath, compute relative path from rootDir
540
        if (!pkgPath) {
1,470✔
541
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
542
        }
543

544
        assert.ok(srcPath, 'fileEntry.src is required');
1,470✔
545
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,470✔
546

547
        return {
1,470✔
548
            srcPath: srcPath,
549
            //remove leading slash from pkgPath
550
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
551
        };
552
    }
553

554
    /**
555
     * Remove any leading `pkg:/` found in the path
556
     */
557
    private removePkgPrefix(path: string) {
558
        return path.replace(/^pkg:\//i, '');
1,469✔
559
    }
560

561
    /**
562
     * Ensure source scope is created.
563
     * Note: automatically called internally, and no-op if it exists already.
564
     */
565
    public createSourceScope() {
566
        if (!this.scopes.source) {
1,460✔
567
            const sourceScope = new Scope('source', this, 'scope:source');
1,082✔
568
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,082✔
569
            this.addScope(sourceScope);
1,082✔
570
            this.plugins.emit('afterScopeCreate', sourceScope);
1,082✔
571
        }
572
    }
573

574
    /**
575
     * Find the file by its absolute path. This is case INSENSITIVE, since
576
     * Roku is a case insensitive file system. It is an error to have multiple files
577
     * with the same path with only case being different.
578
     * @param srcPath the absolute path to the file
579
     * @deprecated use `getFile` instead, which auto-detects the path type
580
     */
581
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
582
        srcPath = s`${srcPath}`;
×
583
        for (let filePath in this.files) {
×
584
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
585
                return this.files[filePath] as T;
×
586
            }
587
        }
588
    }
589

590
    /**
591
     * Get a list of files for the given (platform-normalized) pkgPath array.
592
     * Missing files are just ignored.
593
     * @deprecated use `getFiles` instead, which auto-detects the path types
594
     */
595
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
596
        return pkgPaths
×
597
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
598
            .filter(file => file !== undefined) as T;
×
599
    }
600

601
    /**
602
     * Get a file with the specified (platform-normalized) pkg path.
603
     * If not found, return undefined
604
     * @deprecated use `getFile` instead, which auto-detects the path type
605
     */
606
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
607
        return this.pkgMap[pkgPath.toLowerCase()] as T;
365✔
608
    }
609

610
    /**
611
     * Remove a set of files from the program
612
     * @param srcPaths can be an array of srcPath or destPath strings
613
     * @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
614
     */
615
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
616
        for (let srcPath of srcPaths) {
1✔
617
            this.removeFile(srcPath, normalizePath);
1✔
618
        }
619
    }
620

621
    /**
622
     * Remove a file from the program
623
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
624
     * @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
625
     */
626
    public removeFile(filePath: string, normalizePath = true) {
148✔
627
        this.logger.debug('Program.removeFile()', filePath);
152✔
628

629
        let file = this.getFile(filePath, normalizePath);
152✔
630
        if (file) {
152!
631
            this.plugins.emit('beforeFileDispose', file);
152✔
632

633
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
634
            let scope = this.scopes[file.pkgPath];
152✔
635
            if (scope) {
152✔
636
                this.plugins.emit('beforeScopeDispose', scope);
11✔
637
                scope.dispose();
11✔
638
                //notify dependencies of this scope that it has been removed
639
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
640
                delete this.scopes[file.pkgPath];
11✔
641
                this.plugins.emit('afterScopeDispose', scope);
11✔
642
            }
643
            //remove the file from the program
644
            this.unassignFile(file);
152✔
645

646
            this.dependencyGraph.remove(file.dependencyGraphKey);
152✔
647

648
            //if this is a pkg:/source file, notify the `source` scope that it has changed
649
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
152✔
650
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
101✔
651
            }
652

653
            //if this is a component, remove it from our components map
654
            if (isXmlFile(file)) {
152✔
655
                this.unregisterComponent(file);
11✔
656
            }
657
            //dispose file
658
            file?.dispose();
152!
659
            this.plugins.emit('afterFileDispose', file);
152✔
660
        }
661
    }
662

663
    /**
664
     * Counter used to track which validation run is being logged
665
     */
666
    private validationRunSequence = 1;
1,448✔
667

668
    /**
669
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
670
     */
671
    private validationMinSyncDuration = 75;
1,448✔
672

673
    private validatePromise: Promise<void> | undefined;
674

675
    /**
676
     * Traverse the entire project, and validate all scopes
677
     */
678
    public validate(): void;
679
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
680
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
681
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
682
        const validationRunId = this.validationRunSequence++;
891✔
683
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
891!
684

685
        let previousValidationPromise = this.validatePromise;
891✔
686
        const deferred = new Deferred();
891✔
687

688
        if (options?.async) {
891✔
689
            //we're async, so create a new promise chain to resolve after this validation is done
690
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
152✔
691
                return deferred.promise;
152✔
692
            });
693

694
            //we are not async but there's a pending promise, then we cannot run this validation
695
        } else if (previousValidationPromise !== undefined) {
739!
696
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
697
        }
698

699
        if (options?.async) {
891✔
700
            //we're async, so create a new promise chain to resolve after this validation is done
701
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
152✔
702
                return deferred.promise;
152✔
703
            });
704

705
            //we are not async but there's a pending promise, then we cannot run this validation
706
        } else if (previousValidationPromise !== undefined) {
739!
707
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
708
        }
709

710
        const sequencer = new Sequencer({
891✔
711
            name: 'program.validate',
712
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
5,346✔
713
            minSyncDuration: this.validationMinSyncDuration
714
        });
715

716
        let beforeProgramValidateWasEmitted = false;
891✔
717

718
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
719
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
720
        sequencer
891✔
721
            .once(() => {
722
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
723
                if (options?.async) {
891✔
724
                    return previousValidationPromise;
152✔
725
                }
726
            })
727
            .once(() => {
728
                this.diagnostics = [];
889✔
729
                this.plugins.emit('beforeProgramValidate', this);
889✔
730
                beforeProgramValidateWasEmitted = true;
889✔
731
            })
732
            .forEach(() => Object.values(this.files), (file) => {
889✔
733
                if (!file.isValidated) {
1,125✔
734
                    this.plugins.emit('beforeFileValidate', {
1,066✔
735
                        program: this,
736
                        file: file
737
                    });
738

739
                    //emit an event to allow plugins to contribute to the file validation process
740
                    this.plugins.emit('onFileValidate', {
1,066✔
741
                        program: this,
742
                        file: file
743
                    });
744
                    //call file.validate() IF the file has that function defined
745
                    file.validate?.();
1,066!
746
                    file.isValidated = true;
1,065✔
747

748
                    this.plugins.emit('afterFileValidate', file);
1,065✔
749
                }
750
            })
751
            .forEach(Object.values(this.scopes), (scope) => {
752
                scope.linkSymbolTable();
1,838✔
753
                scope.validate();
1,838✔
754
                scope.unlinkSymbolTable();
1,836✔
755
            })
756
            .once(() => {
757
                this.detectDuplicateComponentNames();
881✔
758
            })
759
            .onCancel(() => {
760
                timeEnd('cancelled');
10✔
761
            })
762
            .onSuccess(() => {
763
                timeEnd();
881✔
764
            })
765
            .onComplete(() => {
766
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
767
                if (beforeProgramValidateWasEmitted) {
891✔
768
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
889✔
769
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
889✔
770
                }
771

772
                //regardless of the success of the validation, mark this run as complete
773
                deferred.resolve();
891✔
774
                //clear the validatePromise which means we're no longer running a validation
775
                this.validatePromise = undefined;
891✔
776
            });
777

778
        //run the sequencer in async mode if enabled
779
        if (options?.async) {
891✔
780
            return sequencer.run();
152✔
781

782
            //run the sequencer in sync mode
783
        } else {
784
            return sequencer.runSync();
739✔
785
        }
786
    }
787

788
    /**
789
     * Flag all duplicate component names
790
     */
791
    private detectDuplicateComponentNames() {
792
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
881✔
793
            const file = this.files[filePath];
1,116✔
794
            //if this is an XmlFile, and it has a valid `componentName` property
795
            if (isXmlFile(file) && file.componentName?.text) {
1,116✔
796
                let lowerName = file.componentName.text.toLowerCase();
203✔
797
                if (!map[lowerName]) {
203✔
798
                    map[lowerName] = [];
200✔
799
                }
800
                map[lowerName].push(file);
203✔
801
            }
802
            return map;
1,116✔
803
        }, {});
804

805
        for (let name in componentsByName) {
881✔
806
            const xmlFiles = componentsByName[name];
200✔
807
            //add diagnostics for every duplicate component with this name
808
            if (xmlFiles.length > 1) {
200✔
809
                for (let xmlFile of xmlFiles) {
3✔
810
                    const { componentName } = xmlFile;
6✔
811
                    this.diagnostics.push({
6✔
812
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
813
                        range: xmlFile.componentName.range,
814
                        file: xmlFile,
815
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
816
                            return {
6✔
817
                                location: util.createLocation(
818
                                    URI.file(xmlFile.srcPath ?? xmlFile.srcPath).toString(),
18!
819
                                    x.componentName.range
820
                                ),
821
                                message: 'Also defined here'
822
                            };
823
                        })
824
                    });
825
                }
826
            }
827
        }
828
    }
829

830
    /**
831
     * Get the files for a list of filePaths
832
     * @param filePaths can be an array of srcPath or a destPath strings
833
     * @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
834
     */
835
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
24✔
836
        return filePaths
24✔
837
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
838
            .filter(file => file !== undefined) as T[];
30✔
839
    }
840

841
    /**
842
     * Get the file at the given path
843
     * @param filePath can be a srcPath or a destPath
844
     * @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
845
     */
846
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
6,010✔
847
        if (typeof filePath !== 'string') {
7,689✔
848
            return undefined;
1,662✔
849
        } else if (path.isAbsolute(filePath)) {
6,027✔
850
            return this.files[
3,145✔
851
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,145✔
852
            ] as T;
853
        } else {
854
            return this.pkgMap[
2,882✔
855
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,882!
856
            ] as T;
857
        }
858
    }
859

860
    /**
861
     * Get a list of all scopes the file is loaded into
862
     * @param file the file
863
     */
864
    public getScopesForFile(file: XmlFile | BrsFile | string) {
865

866
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
554✔
867

868
        let result = [] as Scope[];
554✔
869
        if (resolvedFile) {
554✔
870
            for (let key in this.scopes) {
553✔
871
                let scope = this.scopes[key];
1,143✔
872

873
                if (scope.hasFile(resolvedFile)) {
1,143✔
874
                    result.push(scope);
564✔
875
                }
876
            }
877
        }
878
        return result;
554✔
879
    }
880

881
    /**
882
     * Get the first found scope for a file.
883
     */
884
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
885
        for (let key in this.scopes) {
2,540✔
886
            let scope = this.scopes[key];
6,376✔
887

888
            if (scope.hasFile(file)) {
6,376✔
889
                return scope;
2,401✔
890
            }
891
        }
892
    }
893

894
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
895
        let results = new Map<Statement, FileLink<Statement>>();
39✔
896
        const filesSearched = new Set<BrsFile>();
39✔
897
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
898
        let lowerName = name?.toLowerCase();
39!
899
        //look through all files in scope for matches
900
        for (const scope of this.getScopesForFile(originFile)) {
39✔
901
            for (const file of scope.getAllFiles()) {
39✔
902
                if (isXmlFile(file) || filesSearched.has(file)) {
45✔
903
                    continue;
3✔
904
                }
905
                filesSearched.add(file);
42✔
906

907
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
42✔
908
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
909
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
910
                        if (!results.has(statement)) {
36!
911
                            results.set(statement, { item: statement, file: file });
36✔
912
                        }
913
                    }
914
                }
915
            }
916
        }
917
        return [...results.values()];
39✔
918
    }
919

920
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
921
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
922
        const filesSearched = new Set<BrsFile>();
8✔
923

924
        //get all function names for the xml file and parents
925
        let funcNames = new Set<string>();
8✔
926
        let currentScope = scope;
8✔
927
        while (isXmlScope(currentScope)) {
8✔
928
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
929
                if (!filterName || name === filterName) {
14!
930
                    funcNames.add(name);
14✔
931
                }
932
            }
933
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
934
        }
935

936
        //look through all files in scope for matches
937
        for (const file of scope.getOwnFiles()) {
8✔
938
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
939
                continue;
8✔
940
            }
941
            filesSearched.add(file);
8✔
942

943
            for (const statement of file.parser.references.functionStatements) {
8✔
944
                if (funcNames.has(statement.name.text)) {
13!
945
                    if (!results.has(statement)) {
13!
946
                        results.set(statement, { item: statement, file: file });
13✔
947
                    }
948
                }
949
            }
950
        }
951
        return [...results.values()];
8✔
952
    }
953

954
    /**
955
     * Find all available completion items at the given position
956
     * @param filePath can be a srcPath or a destPath
957
     * @param position the position (line & column) where completions should be found
958
     */
959
    public getCompletions(filePath: string, position: Position) {
960
        let file = this.getFile(filePath);
77✔
961
        if (!file) {
77!
962
            return [];
×
963
        }
964

965
        //find the scopes for this file
966
        let scopes = this.getScopesForFile(file);
77✔
967

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

971
        const event: ProvideCompletionsEvent = {
77✔
972
            program: this,
973
            file: file,
974
            scopes: scopes,
975
            position: position,
976
            completions: []
977
        };
978

979
        this.plugins.emit('beforeProvideCompletions', event);
77✔
980

981
        this.plugins.emit('provideCompletions', event);
77✔
982

983
        this.plugins.emit('afterProvideCompletions', event);
77✔
984

985
        return event.completions;
77✔
986
    }
987

988
    /**
989
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
990
     */
991
    public getWorkspaceSymbols() {
992
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
993
            program: this,
994
            workspaceSymbols: []
995
        };
996
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
997
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
998
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
999
        return event.workspaceSymbols;
22✔
1000
    }
1001

1002
    /**
1003
     * Given a position in a file, if the position is sitting on some type of identifier,
1004
     * go to the definition of that identifier (where this thing was first defined)
1005
     */
1006
    public getDefinition(srcPath: string, position: Position): Location[] {
1007
        let file = this.getFile(srcPath);
19✔
1008
        if (!file) {
19!
1009
            return [];
×
1010
        }
1011

1012
        const event: ProvideDefinitionEvent = {
19✔
1013
            program: this,
1014
            file: file,
1015
            position: position,
1016
            definitions: []
1017
        };
1018

1019
        this.plugins.emit('beforeProvideDefinition', event);
19✔
1020
        this.plugins.emit('provideDefinition', event);
19✔
1021
        this.plugins.emit('afterProvideDefinition', event);
19✔
1022
        return event.definitions;
19✔
1023
    }
1024

1025
    /**
1026
     * Get hover information for a file and position
1027
     */
1028
    public getHover(srcPath: string, position: Position): Hover[] {
1029
        let file = this.getFile(srcPath);
30✔
1030
        let result: Hover[];
1031
        if (file) {
30!
1032
            const event = {
30✔
1033
                program: this,
1034
                file: file,
1035
                position: position,
1036
                scopes: this.getScopesForFile(file),
1037
                hovers: []
1038
            } as ProvideHoverEvent;
1039
            this.plugins.emit('beforeProvideHover', event);
30✔
1040
            this.plugins.emit('provideHover', event);
30✔
1041
            this.plugins.emit('afterProvideHover', event);
30✔
1042
            result = event.hovers;
30✔
1043
        }
1044

1045
        return result ?? [];
30!
1046
    }
1047

1048
    /**
1049
     * Get full list of document symbols for a file
1050
     * @param srcPath path to the file
1051
     */
1052
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1053
        let file = this.getFile(srcPath);
24✔
1054
        if (file) {
24!
1055
            const event: ProvideDocumentSymbolsEvent = {
24✔
1056
                program: this,
1057
                file: file,
1058
                documentSymbols: []
1059
            };
1060
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1061
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1062
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1063
            return event.documentSymbols;
24✔
1064
        } else {
1065
            return undefined;
×
1066
        }
1067
    }
1068

1069
    /**
1070
     * Compute code actions for the given file and range
1071
     */
1072
    public getCodeActions(srcPath: string, range: Range) {
1073
        const codeActions = [] as CodeAction[];
15✔
1074
        const file = this.getFile(srcPath);
15✔
1075
        if (file) {
15✔
1076
            const diagnostics = this
14✔
1077
                //get all current diagnostics (filtered by diagnostic filters)
1078
                .getDiagnostics()
1079
                //only keep diagnostics related to this file
1080
                .filter(x => x.file === file)
30✔
1081
                //only keep diagnostics that touch this range
1082
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
14✔
1083

1084
            const scopes = this.getScopesForFile(file);
14✔
1085

1086
            this.plugins.emit('onGetCodeActions', {
14✔
1087
                program: this,
1088
                file: file,
1089
                range: range,
1090
                diagnostics: diagnostics,
1091
                scopes: scopes,
1092
                codeActions: codeActions
1093
            });
1094
        }
1095
        return codeActions;
15✔
1096
    }
1097

1098
    /**
1099
     * Compute "source fix all" code actions for the given file.
1100
     * Fires the `onGetSourceFixAllCodeActions` plugin event with all diagnostics for the file (no range filter),
1101
     * then converts each contributed SourceFixAllCodeAction into an LSP CodeAction.
1102
     */
1103
    public getSourceFixAllCodeActions(srcPath: string): CodeAction[] {
NEW
1104
        const actions: SourceFixAllCodeAction[] = [];
×
NEW
1105
        const file = this.getFile(srcPath);
×
NEW
1106
        if (file) {
×
NEW
1107
            const diagnostics = this
×
1108
                .getDiagnostics()
NEW
1109
                .filter(x => x.file === file);
×
NEW
1110
            const scopes = this.getScopesForFile(file);
×
NEW
1111
            this.plugins.emit('onGetSourceFixAllCodeActions', {
×
1112
                program: this,
1113
                file: file,
1114
                diagnostics: diagnostics,
1115
                scopes: scopes,
1116
                actions: actions
1117
            } as OnGetSourceFixAllCodeActionsEvent);
1118
        }
NEW
1119
        return actions.map(action => codeActionUtil.createCodeAction({
×
1120
            ...action,
1121
            kind: action.kind ?? 'source.fixAll.brighterscript' as any
×
1122
        }));
1123
    }
1124

1125
    /**
1126
     * Get semantic tokens for the specified file
1127
     */
1128
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1129
        const file = this.getFile(srcPath);
16✔
1130
        if (file) {
16!
1131
            const result = [] as SemanticToken[];
16✔
1132
            this.plugins.emit('onGetSemanticTokens', {
16✔
1133
                program: this,
1134
                file: file,
1135
                scopes: this.getScopesForFile(file),
1136
                semanticTokens: result
1137
            });
1138
            return result;
16✔
1139
        }
1140
    }
1141

1142
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1143
        let file: BrsFile = this.getFile(filePath);
188✔
1144
        if (!file || !isBrsFile(file)) {
188✔
1145
            return [];
3✔
1146
        }
1147
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1148
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1149
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1150
    }
1151

1152
    public getReferences(srcPath: string, position: Position): Location[] {
1153
        //find the file
1154
        let file = this.getFile(srcPath);
4✔
1155
        if (!file) {
4!
1156
            return null;
×
1157
        }
1158

1159
        const event: ProvideReferencesEvent = {
4✔
1160
            program: this,
1161
            file: file,
1162
            position: position,
1163
            references: []
1164
        };
1165

1166
        this.plugins.emit('beforeProvideReferences', event);
4✔
1167
        this.plugins.emit('provideReferences', event);
4✔
1168
        this.plugins.emit('afterProvideReferences', event);
4✔
1169

1170
        return event.references;
4✔
1171
    }
1172

1173
    /**
1174
     * Get a list of all script imports, relative to the specified pkgPath
1175
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1176
     */
1177
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1178
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1179

1180
        let result = [] as CompletionItem[];
3✔
1181
        /**
1182
         * hashtable to prevent duplicate results
1183
         */
1184
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1185

1186
        //restrict to only .brs files
1187
        for (let key in this.files) {
3✔
1188
            let file = this.files[key];
4✔
1189
            if (
4✔
1190
                //is a BrightScript or BrighterScript file
1191
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1192
                //this file is not the current file
1193
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1194
            ) {
1195
                //add the relative path
1196
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1197
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1198
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1199
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1200
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1201
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1202

1203
                    result.push({
3✔
1204
                        label: relativePath,
1205
                        detail: file.srcPath,
1206
                        kind: CompletionItemKind.File,
1207
                        textEdit: {
1208
                            newText: relativePath,
1209
                            range: scriptImport.filePathRange
1210
                        }
1211
                    });
1212

1213
                    //add the absolute path
1214
                    result.push({
3✔
1215
                        label: filePkgPath,
1216
                        detail: file.srcPath,
1217
                        kind: CompletionItemKind.File,
1218
                        textEdit: {
1219
                            newText: filePkgPath,
1220
                            range: scriptImport.filePathRange
1221
                        }
1222
                    });
1223
                }
1224
            }
1225
        }
1226
        return result;
3✔
1227
    }
1228

1229
    /**
1230
     * Transpile a single file and get the result as a string.
1231
     * This does not write anything to the file system.
1232
     *
1233
     * This should only be called by `LanguageServer`.
1234
     * Internal usage should call `_getTranspiledFileContents` instead.
1235
     * @param filePath can be a srcPath or a destPath
1236
     */
1237
    public async getTranspiledFileContents(filePath: string) {
1238
        const file = this.getFile(filePath);
4✔
1239
        const fileMap: FileObj[] = [{
4✔
1240
            src: file.srcPath,
1241
            dest: file.pkgPath
1242
        }];
1243
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1244
        const result = this._getTranspiledFileContents(
4✔
1245
            file
1246
        );
1247
        this.afterProgramTranspile(entries, astEditor);
4✔
1248
        return Promise.resolve(result);
4✔
1249
    }
1250

1251
    /**
1252
     * Internal function used to transpile files.
1253
     * This does not write anything to the file system
1254
     */
1255
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1256
        const editor = new AstEditor();
326✔
1257
        this.plugins.emit('beforeFileTranspile', {
326✔
1258
            program: this,
1259
            file: file,
1260
            outputPath: outputPath,
1261
            editor: editor
1262
        });
1263

1264
        //if we have any edits, assume the file needs to be transpiled
1265
        if (editor.hasChanges) {
326✔
1266
            //use the `editor` because it'll track the previous value for us and revert later on
1267
            editor.setProperty(file, 'needsTranspiled', true);
77✔
1268
        }
1269

1270
        //transpile the file
1271
        const result = file.transpile();
326✔
1272

1273
        //generate the typedef if enabled
1274
        let typedef: string;
1275
        if (isBrsFile(file) && this.options.emitDefinitions) {
326✔
1276
            typedef = file.getTypedef();
2✔
1277
        }
1278

1279
        const event: AfterFileTranspileEvent = {
326✔
1280
            program: this,
1281
            file: file,
1282
            outputPath: outputPath,
1283
            editor: editor,
1284
            code: result.code,
1285
            map: result.map,
1286
            typedef: typedef
1287
        };
1288
        this.plugins.emit('afterFileTranspile', event);
326✔
1289

1290
        //undo all `editor` edits that may have been applied to this file.
1291
        editor.undoAll();
326✔
1292

1293
        return {
326✔
1294
            srcPath: file.srcPath,
1295
            pkgPath: file.pkgPath,
1296
            code: event.code,
1297
            map: event.map,
1298
            typedef: event.typedef
1299
        };
1300
    }
1301

1302
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1303
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1304
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1305
            collection[s`${entry.src}`] = entry;
20✔
1306
            return collection;
20✔
1307
        }, {});
1308

1309
        const getOutputPath = (file: BscFile) => {
37✔
1310
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1311
            if (!filePathObj) {
77✔
1312
                //this file has been added in-memory, from a plugin, for example
1313
                filePathObj = {
47✔
1314
                    //add an interpolated src path (since it doesn't actually exist in memory)
1315
                    src: `bsc:/${file.pkgPath}`,
1316
                    dest: file.pkgPath
1317
                };
1318
            }
1319
            //replace the file extension
1320
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1321
            //prepend the staging folder path
1322
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1323
            return outputPath;
77✔
1324
        };
1325

1326
        const entries = Object.values(this.files)
37✔
1327
            //only include the files from fileEntries
1328
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1329
            .map(file => {
1330
                return {
18✔
1331
                    file: file,
1332
                    outputPath: getOutputPath(file)
1333
                };
1334
            })
1335
            //sort the entries to make transpiling more deterministic
1336
            .sort((a, b) => {
1337
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1338
            });
1339

1340
        const astEditor = new AstEditor();
37✔
1341

1342
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1343
        return {
37✔
1344
            entries: entries,
1345
            getOutputPath: getOutputPath,
1346
            astEditor: astEditor
1347
        };
1348
    }
1349

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

1353
        const processedFiles = new Set<string>();
32✔
1354

1355
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1356
            //find the file in the program
1357
            const file = this.getFile(srcPath);
35✔
1358
            //mark this file as processed so we don't process it more than once
1359
            processedFiles.add(outputPath?.toLowerCase());
35!
1360

1361
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1362
                //skip transpiling typedef files
1363
                if (isBrsFile(file) && file.isTypedef) {
34✔
1364
                    return;
1✔
1365
                }
1366

1367
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1368

1369
                //make sure the full dir path exists
1370
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1371

1372
                if (await fsExtra.pathExists(outputPath)) {
33!
1373
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1374
                }
1375
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1376
                await Promise.all([
33✔
1377
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1378
                    writeMapPromise
1379
                ]);
1380

1381
                if (fileTranspileResult.typedef) {
33✔
1382
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1383
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1384
                }
1385
            }
1386
        };
1387

1388
        let promises = entries.map(async (entry) => {
32✔
1389
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1390
        });
1391

1392
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1393
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1394
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1395
        }
1396
        await Promise.all(promises);
32✔
1397

1398
        //transpile any new files that plugins added since the start of this transpile process
1399
        do {
32✔
1400
            promises = [];
52✔
1401
            for (const key in this.files) {
52✔
1402
                const file = this.files[key];
59✔
1403
                //this is a new file
1404
                const outputPath = getOutputPath(file);
59✔
1405
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1406
                    promises.push(
23✔
1407
                        transpileFile(file?.srcPath, outputPath)
69!
1408
                    );
1409
                }
1410
            }
1411
            if (promises.length > 0) {
52✔
1412
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1413
                await Promise.all(promises);
20✔
1414
            }
1415
        }
1416
        while (promises.length > 0);
1417
        this.afterProgramTranspile(entries, astEditor);
32✔
1418
    }
1419

1420
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1421
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1422
        astEditor.undoAll();
36✔
1423
    }
1424

1425
    /**
1426
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1427
     */
1428
    public findFilesForFunction(functionName: string) {
1429
        const files = [] as BscFile[];
7✔
1430
        const lowerFunctionName = functionName.toLowerCase();
7✔
1431
        //find every file with this function defined
1432
        for (const file of Object.values(this.files)) {
7✔
1433
            if (isBrsFile(file)) {
25✔
1434
                //TODO handle namespace-relative function calls
1435
                //if the file has a function with this name
1436
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1437
                    files.push(file);
2✔
1438
                }
1439
            }
1440
        }
1441
        return files;
7✔
1442
    }
1443

1444
    /**
1445
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1446
     */
1447
    public findFilesForClass(className: string) {
1448
        const files = [] as BscFile[];
7✔
1449
        const lowerClassName = className.toLowerCase();
7✔
1450
        //find every file with this class defined
1451
        for (const file of Object.values(this.files)) {
7✔
1452
            if (isBrsFile(file)) {
25✔
1453
                //TODO handle namespace-relative classes
1454
                //if the file has a function with this name
1455
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1456
                    files.push(file);
1✔
1457
                }
1458
            }
1459
        }
1460
        return files;
7✔
1461
    }
1462

1463
    public findFilesForNamespace(name: string) {
1464
        const files = [] as BscFile[];
7✔
1465
        const lowerName = name.toLowerCase();
7✔
1466
        //find every file with this class defined
1467
        for (const file of Object.values(this.files)) {
7✔
1468
            if (isBrsFile(file)) {
25✔
1469
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1470
                    const namespaceName = x.name.toLowerCase();
7✔
1471
                    return (
7✔
1472
                        //the namespace name matches exactly
1473
                        namespaceName === lowerName ||
9✔
1474
                        //the full namespace starts with the name (honoring the part boundary)
1475
                        namespaceName.startsWith(lowerName + '.')
1476
                    );
1477
                })) {
1478
                    files.push(file);
6✔
1479
                }
1480
            }
1481
        }
1482
        return files;
7✔
1483
    }
1484

1485
    public findFilesForEnum(name: string) {
1486
        const files = [] as BscFile[];
8✔
1487
        const lowerName = name.toLowerCase();
8✔
1488
        //find every file with this class defined
1489
        for (const file of Object.values(this.files)) {
8✔
1490
            if (isBrsFile(file)) {
26✔
1491
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1492
                    files.push(file);
1✔
1493
                }
1494
            }
1495
        }
1496
        return files;
8✔
1497
    }
1498

1499
    private _manifest: Map<string, string>;
1500

1501
    /**
1502
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1503
     * @param parsedManifest The manifest map to read from and modify
1504
     */
1505
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1506
        // Lift the bs_consts defined in the manifest
1507
        let bsConsts = getBsConst(parsedManifest, false);
19✔
1508

1509
        // Override or delete any bs_consts defined in the bs config
1510
        for (const key in this.options?.manifest?.bs_const) {
19!
1511
            const value = this.options.manifest.bs_const[key];
3✔
1512
            if (value === null) {
3✔
1513
                bsConsts.delete(key);
1✔
1514
            } else {
1515
                bsConsts.set(key, value);
2✔
1516
            }
1517
        }
1518

1519
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1520
        let constString = '';
19✔
1521
        for (const [key, value] of bsConsts) {
19✔
1522
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1523
        }
1524

1525
        // Set the updated bs_const value
1526
        parsedManifest.set('bs_const', constString);
19✔
1527
    }
1528

1529
    /**
1530
     * Try to find and load the manifest into memory
1531
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1532
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1533
     */
1534
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
987✔
1535
        //if we already have a manifest instance, and should not replace...then don't replace
1536
        if (!replaceIfAlreadyLoaded && this._manifest) {
999!
1537
            return;
×
1538
        }
1539
        let manifestPath = manifestFileObj
999✔
1540
            ? manifestFileObj.src
999✔
1541
            : path.join(this.options.rootDir, 'manifest');
1542

1543
        try {
999✔
1544
            // we only load this manifest once, so do it sync to improve speed downstream
1545
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
999✔
1546
            const parsedManifest = parseManifest(contents);
19✔
1547
            this.buildBsConstsIntoParsedManifest(parsedManifest);
19✔
1548
            this._manifest = parsedManifest;
19✔
1549
        } catch (e) {
1550
            this._manifest = new Map();
980✔
1551
        }
1552
    }
1553

1554
    /**
1555
     * Get a map of the manifest information
1556
     */
1557
    public getManifest() {
1558
        if (!this._manifest) {
1,312✔
1559
            this.loadManifest();
986✔
1560
        }
1561
        return this._manifest;
1,312✔
1562
    }
1563

1564
    public dispose() {
1565
        this.plugins.emit('beforeProgramDispose', { program: this });
1,280✔
1566

1567
        for (let filePath in this.files) {
1,280✔
1568
            this.files[filePath].dispose();
1,257✔
1569
        }
1570
        for (let name in this.scopes) {
1,280✔
1571
            this.scopes[name].dispose();
2,530✔
1572
        }
1573
        this.globalScope.dispose();
1,280✔
1574
        this.dependencyGraph.dispose();
1,280✔
1575
    }
1576
}
1577

1578
export interface FileTranspileResult {
1579
    srcPath: string;
1580
    pkgPath: string;
1581
    code: string;
1582
    map: SourceMapGenerator;
1583
    typedef: string;
1584
}
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