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

rokucommunity / brighterscript / #13729

10 Feb 2025 06:43PM UTC coverage: 89.113% (+1.0%) from 88.149%
#13729

push

web-flow
Language Server Rewrite (#993)

7461 of 8823 branches covered (84.56%)

Branch coverage included in aggregate %.

1149 of 1263 new or added lines in 28 files covered. (90.97%)

23 existing lines in 5 files now uncovered.

9794 of 10540 relevant lines covered (92.92%)

1836.2 hits per line

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

93.92
/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 } 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
export class Program {
1✔
61
    constructor(
62
        /**
63
         * The root directory for this program
64
         */
65
        options: BsConfig,
66
        logger?: Logger,
67
        plugins?: PluginInterface
68
    ) {
69
        this.options = util.normalizeConfig(options);
1,331✔
70
        this.logger = logger ?? createLogger(options);
1,331✔
71
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,331✔
72

73
        //inject the bsc plugin as the first plugin in the stack.
74
        this.plugins.addFirst(new BscPlugin());
1,331✔
75

76
        //normalize the root dir path
77
        this.options.rootDir = util.getRootDir(this.options);
1,331✔
78

79
        this.createGlobalScope();
1,331✔
80
    }
81

82
    public options: FinalizedBsConfig;
83
    public logger: Logger;
84

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

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

107
    private diagnosticFilterer = new DiagnosticFilterer();
1,331✔
108

109
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,331✔
110

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

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

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

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

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

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

146
    public get bslibPrefix() {
147
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
388✔
148
            return 'rokucommunity_bslib';
3✔
149
        } else {
150
            return 'bslib';
385✔
151
        }
152
    }
153

154

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

161
    private scopes = {} as Record<string, Scope>;
1,331✔
162

163
    protected addScope(scope: Scope) {
164
        this.scopes[scope.name] = scope;
1,217✔
165
    }
166

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

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

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

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

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

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

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

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

271
            let diagnostics = [...this.diagnostics];
788✔
272

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

437
                //notify plugins that this file has finished parsing
438
                this.plugins.emit('afterFileParse', brsFile);
1,121✔
439

440
                file = brsFile;
1,121✔
441

442
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,121✔
443

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

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

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

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

470
                file = xmlFile;
231✔
471

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

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

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

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

504
        assert.ok(fileParam, 'fileParam is required');
1,363✔
505

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

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

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

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

542
        assert.ok(srcPath, 'fileEntry.src is required');
1,363✔
543
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,363✔
544

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

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

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

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

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

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

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

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

627
        let file = this.getFile(filePath, normalizePath);
150✔
628
        if (file) {
150!
629
            this.plugins.emit('beforeFileDispose', file);
150✔
630

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

644
            this.dependencyGraph.remove(file.dependencyGraphKey);
150✔
645

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

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

661
    /**
662
     * Counter used to track which validation run is being logged
663
     */
664
    private validationRunSequence = 1;
1,331✔
665

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

671
    private validatePromise: Promise<void> | undefined;
672

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

683
        let previousValidationPromise = this.validatePromise;
778✔
684
        const deferred = new Deferred();
778✔
685

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

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

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

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

708
        const sequencer = new Sequencer({
778✔
709
            name: 'program.validate',
710
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
4,668✔
711
            minSyncDuration: this.validationMinSyncDuration
712
        });
713

714
        let beforeProgramValidateWasEmitted = false;
778✔
715

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

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

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

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

776
        //run the sequencer in async mode if enabled
777
        if (options?.async) {
778✔
778
            return sequencer.run();
122✔
779

780
            //run the sequencer in sync mode
781
        } else {
782
            return sequencer.runSync();
656✔
783
        }
784
    }
785

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

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

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

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

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

864
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
544✔
865

866
        let result = [] as Scope[];
544✔
867
        if (resolvedFile) {
544✔
868
            for (let key in this.scopes) {
543✔
869
                let scope = this.scopes[key];
1,123✔
870

871
                if (scope.hasFile(resolvedFile)) {
1,123✔
872
                    result.push(scope);
554✔
873
                }
874
            }
875
        }
876
        return result;
544✔
877
    }
878

879
    /**
880
     * Get the first found scope for a file.
881
     */
882
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
883
        for (let key in this.scopes) {
2,286✔
884
            let scope = this.scopes[key];
5,866✔
885

886
            if (scope.hasFile(file)) {
5,866✔
887
                return scope;
2,147✔
888
            }
889
        }
890
    }
891

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

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

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

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

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

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

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

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

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

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

977
        this.plugins.emit('beforeProvideCompletions', event);
77✔
978

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

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

983
        return event.completions;
77✔
984
    }
985

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

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

1010
        const event: ProvideDefinitionEvent = {
13✔
1011
            program: this,
1012
            file: file,
1013
            position: position,
1014
            definitions: []
1015
        };
1016

1017
        this.plugins.emit('beforeProvideDefinition', event);
13✔
1018
        this.plugins.emit('provideDefinition', event);
13✔
1019
        this.plugins.emit('afterProvideDefinition', event);
13✔
1020
        return event.definitions;
13✔
1021
    }
1022

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

1043
        return result ?? [];
30!
1044
    }
1045

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

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

1082
            const scopes = this.getScopesForFile(file);
10✔
1083

1084
            this.plugins.emit('onGetCodeActions', {
10✔
1085
                program: this,
1086
                file: file,
1087
                range: range,
1088
                diagnostics: diagnostics,
1089
                scopes: scopes,
1090
                codeActions: codeActions
1091
            });
1092
        }
1093
        return codeActions;
11✔
1094
    }
1095

1096
    /**
1097
     * Get semantic tokens for the specified file
1098
     */
1099
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1100
        const file = this.getFile(srcPath);
16✔
1101
        if (file) {
16!
1102
            const result = [] as SemanticToken[];
16✔
1103
            this.plugins.emit('onGetSemanticTokens', {
16✔
1104
                program: this,
1105
                file: file,
1106
                scopes: this.getScopesForFile(file),
1107
                semanticTokens: result
1108
            });
1109
            return result;
16✔
1110
        }
1111
    }
1112

1113
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1114
        let file: BrsFile = this.getFile(filePath);
185✔
1115
        if (!file || !isBrsFile(file)) {
185✔
1116
            return [];
3✔
1117
        }
1118
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1119
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1120
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1121
    }
1122

1123
    public getReferences(srcPath: string, position: Position): Location[] {
1124
        //find the file
1125
        let file = this.getFile(srcPath);
4✔
1126
        if (!file) {
4!
1127
            return null;
×
1128
        }
1129

1130
        const event: ProvideReferencesEvent = {
4✔
1131
            program: this,
1132
            file: file,
1133
            position: position,
1134
            references: []
1135
        };
1136

1137
        this.plugins.emit('beforeProvideReferences', event);
4✔
1138
        this.plugins.emit('provideReferences', event);
4✔
1139
        this.plugins.emit('afterProvideReferences', event);
4✔
1140

1141
        return event.references;
4✔
1142
    }
1143

1144
    /**
1145
     * Get a list of all script imports, relative to the specified pkgPath
1146
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1147
     */
1148
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1149
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1150

1151
        let result = [] as CompletionItem[];
3✔
1152
        /**
1153
         * hashtable to prevent duplicate results
1154
         */
1155
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1156

1157
        //restrict to only .brs files
1158
        for (let key in this.files) {
3✔
1159
            let file = this.files[key];
4✔
1160
            if (
4✔
1161
                //is a BrightScript or BrighterScript file
1162
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1163
                //this file is not the current file
1164
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1165
            ) {
1166
                //add the relative path
1167
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1168
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1169
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1170
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1171
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1172
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1173

1174
                    result.push({
3✔
1175
                        label: relativePath,
1176
                        detail: file.srcPath,
1177
                        kind: CompletionItemKind.File,
1178
                        textEdit: {
1179
                            newText: relativePath,
1180
                            range: scriptImport.filePathRange
1181
                        }
1182
                    });
1183

1184
                    //add the absolute path
1185
                    result.push({
3✔
1186
                        label: filePkgPath,
1187
                        detail: file.srcPath,
1188
                        kind: CompletionItemKind.File,
1189
                        textEdit: {
1190
                            newText: filePkgPath,
1191
                            range: scriptImport.filePathRange
1192
                        }
1193
                    });
1194
                }
1195
            }
1196
        }
1197
        return result;
3✔
1198
    }
1199

1200
    /**
1201
     * Transpile a single file and get the result as a string.
1202
     * This does not write anything to the file system.
1203
     *
1204
     * This should only be called by `LanguageServer`.
1205
     * Internal usage should call `_getTranspiledFileContents` instead.
1206
     * @param filePath can be a srcPath or a destPath
1207
     */
1208
    public async getTranspiledFileContents(filePath: string) {
1209
        const file = this.getFile(filePath);
4✔
1210
        const fileMap: FileObj[] = [{
4✔
1211
            src: file.srcPath,
1212
            dest: file.pkgPath
1213
        }];
1214
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1215
        const result = this._getTranspiledFileContents(
4✔
1216
            file
1217
        );
1218
        this.afterProgramTranspile(entries, astEditor);
4✔
1219
        return Promise.resolve(result);
4✔
1220
    }
1221

1222
    /**
1223
     * Internal function used to transpile files.
1224
     * This does not write anything to the file system
1225
     */
1226
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1227
        const editor = new AstEditor();
288✔
1228
        this.plugins.emit('beforeFileTranspile', {
288✔
1229
            program: this,
1230
            file: file,
1231
            outputPath: outputPath,
1232
            editor: editor
1233
        });
1234

1235
        //if we have any edits, assume the file needs to be transpiled
1236
        if (editor.hasChanges) {
288✔
1237
            //use the `editor` because it'll track the previous value for us and revert later on
1238
            editor.setProperty(file, 'needsTranspiled', true);
61✔
1239
        }
1240

1241
        //transpile the file
1242
        const result = file.transpile();
288✔
1243

1244
        //generate the typedef if enabled
1245
        let typedef: string;
1246
        if (isBrsFile(file) && this.options.emitDefinitions) {
288✔
1247
            typedef = file.getTypedef();
2✔
1248
        }
1249

1250
        const event: AfterFileTranspileEvent = {
288✔
1251
            program: this,
1252
            file: file,
1253
            outputPath: outputPath,
1254
            editor: editor,
1255
            code: result.code,
1256
            map: result.map,
1257
            typedef: typedef
1258
        };
1259
        this.plugins.emit('afterFileTranspile', event);
288✔
1260

1261
        //undo all `editor` edits that may have been applied to this file.
1262
        editor.undoAll();
288✔
1263

1264
        return {
288✔
1265
            srcPath: file.srcPath,
1266
            pkgPath: file.pkgPath,
1267
            code: event.code,
1268
            map: event.map,
1269
            typedef: event.typedef
1270
        };
1271
    }
1272

1273
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1274
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1275
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1276
            collection[s`${entry.src}`] = entry;
20✔
1277
            return collection;
20✔
1278
        }, {});
1279

1280
        const getOutputPath = (file: BscFile) => {
37✔
1281
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1282
            if (!filePathObj) {
77✔
1283
                //this file has been added in-memory, from a plugin, for example
1284
                filePathObj = {
47✔
1285
                    //add an interpolated src path (since it doesn't actually exist in memory)
1286
                    src: `bsc:/${file.pkgPath}`,
1287
                    dest: file.pkgPath
1288
                };
1289
            }
1290
            //replace the file extension
1291
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1292
            //prepend the staging folder path
1293
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1294
            return outputPath;
77✔
1295
        };
1296

1297
        const entries = Object.values(this.files)
37✔
1298
            //only include the files from fileEntries
1299
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1300
            .map(file => {
1301
                return {
18✔
1302
                    file: file,
1303
                    outputPath: getOutputPath(file)
1304
                };
1305
            })
1306
            //sort the entries to make transpiling more deterministic
1307
            .sort((a, b) => {
1308
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1309
            });
1310

1311
        const astEditor = new AstEditor();
37✔
1312

1313
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1314
        return {
37✔
1315
            entries: entries,
1316
            getOutputPath: getOutputPath,
1317
            astEditor: astEditor
1318
        };
1319
    }
1320

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

1324
        const processedFiles = new Set<string>();
32✔
1325

1326
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1327
            //find the file in the program
1328
            const file = this.getFile(srcPath);
35✔
1329
            //mark this file as processed so we don't process it more than once
1330
            processedFiles.add(outputPath?.toLowerCase());
35!
1331

1332
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1333
                //skip transpiling typedef files
1334
                if (isBrsFile(file) && file.isTypedef) {
34✔
1335
                    return;
1✔
1336
                }
1337

1338
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1339

1340
                //make sure the full dir path exists
1341
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1342

1343
                if (await fsExtra.pathExists(outputPath)) {
33!
1344
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1345
                }
1346
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1347
                await Promise.all([
33✔
1348
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1349
                    writeMapPromise
1350
                ]);
1351

1352
                if (fileTranspileResult.typedef) {
33✔
1353
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1354
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1355
                }
1356
            }
1357
        };
1358

1359
        let promises = entries.map(async (entry) => {
32✔
1360
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1361
        });
1362

1363
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1364
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1365
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1366
        }
1367
        await Promise.all(promises);
32✔
1368

1369
        //transpile any new files that plugins added since the start of this transpile process
1370
        do {
32✔
1371
            promises = [];
52✔
1372
            for (const key in this.files) {
52✔
1373
                const file = this.files[key];
59✔
1374
                //this is a new file
1375
                const outputPath = getOutputPath(file);
59✔
1376
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1377
                    promises.push(
23✔
1378
                        transpileFile(file?.srcPath, outputPath)
69!
1379
                    );
1380
                }
1381
            }
1382
            if (promises.length > 0) {
52✔
1383
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1384
                await Promise.all(promises);
20✔
1385
            }
1386
        }
1387
        while (promises.length > 0);
1388
        this.afterProgramTranspile(entries, astEditor);
32✔
1389
    }
1390

1391
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1392
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1393
        astEditor.undoAll();
36✔
1394
    }
1395

1396
    /**
1397
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1398
     */
1399
    public findFilesForFunction(functionName: string) {
1400
        const files = [] as BscFile[];
7✔
1401
        const lowerFunctionName = functionName.toLowerCase();
7✔
1402
        //find every file with this function defined
1403
        for (const file of Object.values(this.files)) {
7✔
1404
            if (isBrsFile(file)) {
25✔
1405
                //TODO handle namespace-relative function calls
1406
                //if the file has a function with this name
1407
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1408
                    files.push(file);
2✔
1409
                }
1410
            }
1411
        }
1412
        return files;
7✔
1413
    }
1414

1415
    /**
1416
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1417
     */
1418
    public findFilesForClass(className: string) {
1419
        const files = [] as BscFile[];
7✔
1420
        const lowerClassName = className.toLowerCase();
7✔
1421
        //find every file with this class defined
1422
        for (const file of Object.values(this.files)) {
7✔
1423
            if (isBrsFile(file)) {
25✔
1424
                //TODO handle namespace-relative classes
1425
                //if the file has a function with this name
1426
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1427
                    files.push(file);
1✔
1428
                }
1429
            }
1430
        }
1431
        return files;
7✔
1432
    }
1433

1434
    public findFilesForNamespace(name: string) {
1435
        const files = [] as BscFile[];
7✔
1436
        const lowerName = name.toLowerCase();
7✔
1437
        //find every file with this class defined
1438
        for (const file of Object.values(this.files)) {
7✔
1439
            if (isBrsFile(file)) {
25✔
1440
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1441
                    const namespaceName = x.name.toLowerCase();
7✔
1442
                    return (
7✔
1443
                        //the namespace name matches exactly
1444
                        namespaceName === lowerName ||
9✔
1445
                        //the full namespace starts with the name (honoring the part boundary)
1446
                        namespaceName.startsWith(lowerName + '.')
1447
                    );
1448
                })) {
1449
                    files.push(file);
6✔
1450
                }
1451
            }
1452
        }
1453
        return files;
7✔
1454
    }
1455

1456
    public findFilesForEnum(name: string) {
1457
        const files = [] as BscFile[];
8✔
1458
        const lowerName = name.toLowerCase();
8✔
1459
        //find every file with this class defined
1460
        for (const file of Object.values(this.files)) {
8✔
1461
            if (isBrsFile(file)) {
26✔
1462
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1463
                    files.push(file);
1✔
1464
                }
1465
            }
1466
        }
1467
        return files;
8✔
1468
    }
1469

1470
    private _manifest: Map<string, string>;
1471

1472
    /**
1473
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1474
     * @param parsedManifest The manifest map to read from and modify
1475
     */
1476
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1477
        // Lift the bs_consts defined in the manifest
1478
        let bsConsts = getBsConst(parsedManifest, false);
13✔
1479

1480
        // Override or delete any bs_consts defined in the bs config
1481
        for (const key in this.options?.manifest?.bs_const) {
13!
1482
            const value = this.options.manifest.bs_const[key];
3✔
1483
            if (value === null) {
3✔
1484
                bsConsts.delete(key);
1✔
1485
            } else {
1486
                bsConsts.set(key, value);
2✔
1487
            }
1488
        }
1489

1490
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1491
        let constString = '';
13✔
1492
        for (const [key, value] of bsConsts) {
13✔
1493
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1494
        }
1495

1496
        // Set the updated bs_const value
1497
        parsedManifest.set('bs_const', constString);
13✔
1498
    }
1499

1500
    /**
1501
     * Try to find and load the manifest into memory
1502
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1503
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1504
     */
1505
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
899✔
1506
        //if we already have a manifest instance, and should not replace...then don't replace
1507
        if (!replaceIfAlreadyLoaded && this._manifest) {
905!
1508
            return;
×
1509
        }
1510
        let manifestPath = manifestFileObj
905✔
1511
            ? manifestFileObj.src
905✔
1512
            : path.join(this.options.rootDir, 'manifest');
1513

1514
        try {
905✔
1515
            // we only load this manifest once, so do it sync to improve speed downstream
1516
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
905✔
1517
            const parsedManifest = parseManifest(contents);
13✔
1518
            this.buildBsConstsIntoParsedManifest(parsedManifest);
13✔
1519
            this._manifest = parsedManifest;
13✔
1520
        } catch (e) {
1521
            this._manifest = new Map();
892✔
1522
        }
1523
    }
1524

1525
    /**
1526
     * Get a map of the manifest information
1527
     */
1528
    public getManifest() {
1529
        if (!this._manifest) {
1,203✔
1530
            this.loadManifest();
898✔
1531
        }
1532
        return this._manifest;
1,203✔
1533
    }
1534

1535
    public dispose() {
1536
        this.plugins.emit('beforeProgramDispose', { program: this });
1,175✔
1537

1538
        for (let filePath in this.files) {
1,175✔
1539
            this.files[filePath].dispose();
1,163✔
1540
        }
1541
        for (let name in this.scopes) {
1,175✔
1542
            this.scopes[name].dispose();
2,341✔
1543
        }
1544
        this.globalScope.dispose();
1,175✔
1545
        this.dependencyGraph.dispose();
1,175✔
1546
    }
1547
}
1548

1549
export interface FileTranspileResult {
1550
    srcPath: string;
1551
    pkgPath: string;
1552
    code: string;
1553
    map: SourceMapGenerator;
1554
    typedef: string;
1555
}
1556

1557
export function test(foo) {
1✔
NEW
1558
    const bar = foo + 'Hello';
×
1559

1560

NEW
1561
    return bar;
×
1562
}
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