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

rokucommunity / brighterscript / #13274

04 Nov 2024 02:42PM UTC coverage: 89.088% (+0.9%) from 88.207%
#13274

push

web-flow
Merge 99d15489d into d2d08a75d

7343 of 8689 branches covered (84.51%)

Branch coverage included in aggregate %.

1105 of 1221 new or added lines in 28 files covered. (90.5%)

24 existing lines in 5 files now uncovered.

9688 of 10428 relevant lines covered (92.9%)

1797.47 hits per line

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

94.06
/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

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

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

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

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

59
export class Program {
1✔
60
    constructor(
61
        /**
62
         * The root directory for this program
63
         */
64
        options: BsConfig,
65
        logger?: Logger,
66
        plugins?: PluginInterface
67
    ) {
68
        this.options = util.normalizeConfig(options);
1,311✔
69
        this.logger = logger ?? createLogger(options);
1,311✔
70
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,311✔
71

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

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

78
        this.createGlobalScope();
1,311✔
79
    }
80

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

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

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

106
    private diagnosticFilterer = new DiagnosticFilterer();
1,311✔
107

108
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,311✔
109

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

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

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

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

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

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

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

153

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

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

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

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

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

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

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

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

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

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

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

270
            let diagnostics = [...this.diagnostics];
772✔
271

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

439
                file = brsFile;
1,111✔
440

441
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,111✔
442

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

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

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

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

469
                file = xmlFile;
208✔
470

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

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

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

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

503
        assert.ok(fileParam, 'fileParam is required');
1,330✔
504

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

643
            this.dependencyGraph.remove(file.dependencyGraphKey);
148✔
644

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

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

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

665
    /**
666
     * Traverse the entire project, and validate all scopes
667
     */
668
    public validate(): void;
669
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
670
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
671
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
672
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${this.validationRunSequence++})` : ''}`);
765!
673

674
        const sequencer = new Sequencer({
765✔
675
            name: 'program.validate',
676
            async: options?.async ?? false,
4,590✔
677
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
4,590✔
678
            //how many milliseconds can pass while doing synchronous operations before we register a short timeout
679
            minSyncDuration: 150
680
        });
681

682
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
683
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
684
        return sequencer
765✔
685
            .once(() => {
686
                this.diagnostics = [];
765✔
687
                this.plugins.emit('beforeProgramValidate', this);
765✔
688
            })
689
            .forEach(Object.values(this.files), (file) => {
690
                if (!file.isValidated) {
968✔
691
                    this.plugins.emit('beforeFileValidate', {
937✔
692
                        program: this,
693
                        file: file
694
                    });
695

696
                    //emit an event to allow plugins to contribute to the file validation process
697
                    this.plugins.emit('onFileValidate', {
937✔
698
                        program: this,
699
                        file: file
700
                    });
701
                    //call file.validate() IF the file has that function defined
702
                    file.validate?.();
937!
703
                    file.isValidated = true;
936✔
704

705
                    this.plugins.emit('afterFileValidate', file);
936✔
706
                }
707
            })
708
            .forEach(Object.values(this.scopes), (scope) => {
709
                scope.linkSymbolTable();
1,573✔
710
                scope.validate();
1,573✔
711
                scope.unlinkSymbolTable();
1,573✔
712
            })
713
            .once(() => {
714
                this.detectDuplicateComponentNames();
764✔
715
                this.plugins.emit('afterProgramValidate', this);
764✔
716
            })
717
            .onCancel(() => {
NEW
718
                timeEnd('cancelled');
×
719
            })
720
            .onSuccess(() => {
721
                timeEnd();
764✔
722
            })
723
            .run();
724
    }
725

726
    /**
727
     * Flag all duplicate component names
728
     */
729
    private detectDuplicateComponentNames() {
730
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
764✔
731
            const file = this.files[filePath];
967✔
732
            //if this is an XmlFile, and it has a valid `componentName` property
733
            if (isXmlFile(file) && file.componentName?.text) {
967✔
734
                let lowerName = file.componentName.text.toLowerCase();
157✔
735
                if (!map[lowerName]) {
157✔
736
                    map[lowerName] = [];
154✔
737
                }
738
                map[lowerName].push(file);
157✔
739
            }
740
            return map;
967✔
741
        }, {});
742

743
        for (let name in componentsByName) {
764✔
744
            const xmlFiles = componentsByName[name];
154✔
745
            //add diagnostics for every duplicate component with this name
746
            if (xmlFiles.length > 1) {
154✔
747
                for (let xmlFile of xmlFiles) {
3✔
748
                    const { componentName } = xmlFile;
6✔
749
                    this.diagnostics.push({
6✔
750
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
751
                        range: xmlFile.componentName.range,
752
                        file: xmlFile,
753
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
754
                            return {
6✔
755
                                location: util.createLocation(
756
                                    URI.file(xmlFile.srcPath ?? xmlFile.srcPath).toString(),
18!
757
                                    x.componentName.range
758
                                ),
759
                                message: 'Also defined here'
760
                            };
761
                        })
762
                    });
763
                }
764
            }
765
        }
766
    }
767

768
    /**
769
     * Get the files for a list of filePaths
770
     * @param filePaths can be an array of srcPath or a destPath strings
771
     * @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
772
     */
773
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
24✔
774
        return filePaths
24✔
775
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
776
            .filter(file => file !== undefined) as T[];
30✔
777
    }
778

779
    /**
780
     * Get the file at the given path
781
     * @param filePath can be a srcPath or a destPath
782
     * @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
783
     */
784
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
5,468✔
785
        if (typeof filePath !== 'string') {
7,002✔
786
            return undefined;
1,511✔
787
        } else if (path.isAbsolute(filePath)) {
5,491✔
788
            return this.files[
2,875✔
789
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,875✔
790
            ] as T;
791
        } else {
792
            return this.pkgMap[
2,616✔
793
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
2,616!
794
            ] as T;
795
        }
796
    }
797

798
    /**
799
     * Get a list of all scopes the file is loaded into
800
     * @param file the file
801
     */
802
    public getScopesForFile(file: XmlFile | BrsFile | string) {
803

804
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
496✔
805

806
        let result = [] as Scope[];
496✔
807
        if (resolvedFile) {
496✔
808
            for (let key in this.scopes) {
495✔
809
                let scope = this.scopes[key];
1,027✔
810

811
                if (scope.hasFile(resolvedFile)) {
1,027✔
812
                    result.push(scope);
506✔
813
                }
814
            }
815
        }
816
        return result;
496✔
817
    }
818

819
    /**
820
     * Get the first found scope for a file.
821
     */
822
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
823
        for (let key in this.scopes) {
2,186✔
824
            let scope = this.scopes[key];
5,078✔
825

826
            if (scope.hasFile(file)) {
5,078✔
827
                return scope;
2,047✔
828
            }
829
        }
830
    }
831

832
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
833
        let results = new Map<Statement, FileLink<Statement>>();
39✔
834
        const filesSearched = new Set<BrsFile>();
39✔
835
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
836
        let lowerName = name?.toLowerCase();
39!
837
        //look through all files in scope for matches
838
        for (const scope of this.getScopesForFile(originFile)) {
39✔
839
            for (const file of scope.getAllFiles()) {
39✔
840
                if (isXmlFile(file) || filesSearched.has(file)) {
45✔
841
                    continue;
3✔
842
                }
843
                filesSearched.add(file);
42✔
844

845
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
42✔
846
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
847
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
848
                        if (!results.has(statement)) {
36!
849
                            results.set(statement, { item: statement, file: file });
36✔
850
                        }
851
                    }
852
                }
853
            }
854
        }
855
        return [...results.values()];
39✔
856
    }
857

858
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
859
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
860
        const filesSearched = new Set<BrsFile>();
8✔
861

862
        //get all function names for the xml file and parents
863
        let funcNames = new Set<string>();
8✔
864
        let currentScope = scope;
8✔
865
        while (isXmlScope(currentScope)) {
8✔
866
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
867
                if (!filterName || name === filterName) {
14!
868
                    funcNames.add(name);
14✔
869
                }
870
            }
871
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
872
        }
873

874
        //look through all files in scope for matches
875
        for (const file of scope.getOwnFiles()) {
8✔
876
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
877
                continue;
8✔
878
            }
879
            filesSearched.add(file);
8✔
880

881
            for (const statement of file.parser.references.functionStatements) {
8✔
882
                if (funcNames.has(statement.name.text)) {
13!
883
                    if (!results.has(statement)) {
13!
884
                        results.set(statement, { item: statement, file: file });
13✔
885
                    }
886
                }
887
            }
888
        }
889
        return [...results.values()];
8✔
890
    }
891

892
    /**
893
     * Find all available completion items at the given position
894
     * @param filePath can be a srcPath or a destPath
895
     * @param position the position (line & column) where completions should be found
896
     */
897
    public getCompletions(filePath: string, position: Position) {
898
        let file = this.getFile(filePath);
77✔
899
        if (!file) {
77!
900
            return [];
×
901
        }
902

903
        //find the scopes for this file
904
        let scopes = this.getScopesForFile(file);
77✔
905

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

909
        const event: ProvideCompletionsEvent = {
77✔
910
            program: this,
911
            file: file,
912
            scopes: scopes,
913
            position: position,
914
            completions: []
915
        };
916

917
        this.plugins.emit('beforeProvideCompletions', event);
77✔
918

919
        this.plugins.emit('provideCompletions', event);
77✔
920

921
        this.plugins.emit('afterProvideCompletions', event);
77✔
922

923
        return event.completions;
77✔
924
    }
925

926
    /**
927
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
928
     */
929
    public getWorkspaceSymbols() {
930
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
931
            program: this,
932
            workspaceSymbols: []
933
        };
934
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
935
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
936
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
937
        return event.workspaceSymbols;
22✔
938
    }
939

940
    /**
941
     * Given a position in a file, if the position is sitting on some type of identifier,
942
     * go to the definition of that identifier (where this thing was first defined)
943
     */
944
    public getDefinition(srcPath: string, position: Position): Location[] {
945
        let file = this.getFile(srcPath);
13✔
946
        if (!file) {
13!
947
            return [];
×
948
        }
949

950
        const event: ProvideDefinitionEvent = {
13✔
951
            program: this,
952
            file: file,
953
            position: position,
954
            definitions: []
955
        };
956

957
        this.plugins.emit('beforeProvideDefinition', event);
13✔
958
        this.plugins.emit('provideDefinition', event);
13✔
959
        this.plugins.emit('afterProvideDefinition', event);
13✔
960
        return event.definitions;
13✔
961
    }
962

963
    /**
964
     * Get hover information for a file and position
965
     */
966
    public getHover(srcPath: string, position: Position): Hover[] {
967
        let file = this.getFile(srcPath);
30✔
968
        let result: Hover[];
969
        if (file) {
30!
970
            const event = {
30✔
971
                program: this,
972
                file: file,
973
                position: position,
974
                scopes: this.getScopesForFile(file),
975
                hovers: []
976
            } as ProvideHoverEvent;
977
            this.plugins.emit('beforeProvideHover', event);
30✔
978
            this.plugins.emit('provideHover', event);
30✔
979
            this.plugins.emit('afterProvideHover', event);
30✔
980
            result = event.hovers;
30✔
981
        }
982

983
        return result ?? [];
30!
984
    }
985

986
    /**
987
     * Get full list of document symbols for a file
988
     * @param srcPath path to the file
989
     */
990
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
991
        let file = this.getFile(srcPath);
24✔
992
        if (file) {
24!
993
            const event: ProvideDocumentSymbolsEvent = {
24✔
994
                program: this,
995
                file: file,
996
                documentSymbols: []
997
            };
998
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
999
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1000
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1001
            return event.documentSymbols;
24✔
1002
        } else {
1003
            return undefined;
×
1004
        }
1005
    }
1006

1007
    /**
1008
     * Compute code actions for the given file and range
1009
     */
1010
    public getCodeActions(srcPath: string, range: Range) {
1011
        const codeActions = [] as CodeAction[];
11✔
1012
        const file = this.getFile(srcPath);
11✔
1013
        if (file) {
11✔
1014
            const diagnostics = this
10✔
1015
                //get all current diagnostics (filtered by diagnostic filters)
1016
                .getDiagnostics()
1017
                //only keep diagnostics related to this file
1018
                .filter(x => x.file === file)
26✔
1019
                //only keep diagnostics that touch this range
1020
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
10✔
1021

1022
            const scopes = this.getScopesForFile(file);
10✔
1023

1024
            this.plugins.emit('onGetCodeActions', {
10✔
1025
                program: this,
1026
                file: file,
1027
                range: range,
1028
                diagnostics: diagnostics,
1029
                scopes: scopes,
1030
                codeActions: codeActions
1031
            });
1032
        }
1033
        return codeActions;
11✔
1034
    }
1035

1036
    /**
1037
     * Get semantic tokens for the specified file
1038
     */
1039
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1040
        const file = this.getFile(srcPath);
16✔
1041
        if (file) {
16!
1042
            const result = [] as SemanticToken[];
16✔
1043
            this.plugins.emit('onGetSemanticTokens', {
16✔
1044
                program: this,
1045
                file: file,
1046
                scopes: this.getScopesForFile(file),
1047
                semanticTokens: result
1048
            });
1049
            return result;
16✔
1050
        }
1051
    }
1052

1053
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1054
        let file: BrsFile = this.getFile(filePath);
185✔
1055
        if (!file || !isBrsFile(file)) {
185✔
1056
            return [];
3✔
1057
        }
1058
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1059
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1060
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1061
    }
1062

1063
    public getReferences(srcPath: string, position: Position): Location[] {
1064
        //find the file
1065
        let file = this.getFile(srcPath);
4✔
1066
        if (!file) {
4!
1067
            return null;
×
1068
        }
1069

1070
        const event: ProvideReferencesEvent = {
4✔
1071
            program: this,
1072
            file: file,
1073
            position: position,
1074
            references: []
1075
        };
1076

1077
        this.plugins.emit('beforeProvideReferences', event);
4✔
1078
        this.plugins.emit('provideReferences', event);
4✔
1079
        this.plugins.emit('afterProvideReferences', event);
4✔
1080

1081
        return event.references;
4✔
1082
    }
1083

1084
    /**
1085
     * Get a list of all script imports, relative to the specified pkgPath
1086
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1087
     */
1088
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1089
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1090

1091
        let result = [] as CompletionItem[];
3✔
1092
        /**
1093
         * hashtable to prevent duplicate results
1094
         */
1095
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1096

1097
        //restrict to only .brs files
1098
        for (let key in this.files) {
3✔
1099
            let file = this.files[key];
4✔
1100
            if (
4✔
1101
                //is a BrightScript or BrighterScript file
1102
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1103
                //this file is not the current file
1104
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1105
            ) {
1106
                //add the relative path
1107
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1108
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1109
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1110
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1111
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1112
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1113

1114
                    result.push({
3✔
1115
                        label: relativePath,
1116
                        detail: file.srcPath,
1117
                        kind: CompletionItemKind.File,
1118
                        textEdit: {
1119
                            newText: relativePath,
1120
                            range: scriptImport.filePathRange
1121
                        }
1122
                    });
1123

1124
                    //add the absolute path
1125
                    result.push({
3✔
1126
                        label: filePkgPath,
1127
                        detail: file.srcPath,
1128
                        kind: CompletionItemKind.File,
1129
                        textEdit: {
1130
                            newText: filePkgPath,
1131
                            range: scriptImport.filePathRange
1132
                        }
1133
                    });
1134
                }
1135
            }
1136
        }
1137
        return result;
3✔
1138
    }
1139

1140
    /**
1141
     * Transpile a single file and get the result as a string.
1142
     * This does not write anything to the file system.
1143
     *
1144
     * This should only be called by `LanguageServer`.
1145
     * Internal usage should call `_getTranspiledFileContents` instead.
1146
     * @param filePath can be a srcPath or a destPath
1147
     */
1148
    public async getTranspiledFileContents(filePath: string) {
1149
        const file = this.getFile(filePath);
4✔
1150
        const fileMap: FileObj[] = [{
4✔
1151
            src: file.srcPath,
1152
            dest: file.pkgPath
1153
        }];
1154
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1155
        const result = this._getTranspiledFileContents(
4✔
1156
            file
1157
        );
1158
        this.afterProgramTranspile(entries, astEditor);
4✔
1159
        return Promise.resolve(result);
4✔
1160
    }
1161

1162
    /**
1163
     * Internal function used to transpile files.
1164
     * This does not write anything to the file system
1165
     */
1166
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1167
        const editor = new AstEditor();
286✔
1168
        this.plugins.emit('beforeFileTranspile', {
286✔
1169
            program: this,
1170
            file: file,
1171
            outputPath: outputPath,
1172
            editor: editor
1173
        });
1174

1175
        //if we have any edits, assume the file needs to be transpiled
1176
        if (editor.hasChanges) {
286✔
1177
            //use the `editor` because it'll track the previous value for us and revert later on
1178
            editor.setProperty(file, 'needsTranspiled', true);
61✔
1179
        }
1180

1181
        //transpile the file
1182
        const result = file.transpile();
286✔
1183

1184
        //generate the typedef if enabled
1185
        let typedef: string;
1186
        if (isBrsFile(file) && this.options.emitDefinitions) {
286✔
1187
            typedef = file.getTypedef();
2✔
1188
        }
1189

1190
        const event: AfterFileTranspileEvent = {
286✔
1191
            program: this,
1192
            file: file,
1193
            outputPath: outputPath,
1194
            editor: editor,
1195
            code: result.code,
1196
            map: result.map,
1197
            typedef: typedef
1198
        };
1199
        this.plugins.emit('afterFileTranspile', event);
286✔
1200

1201
        //undo all `editor` edits that may have been applied to this file.
1202
        editor.undoAll();
286✔
1203

1204
        return {
286✔
1205
            srcPath: file.srcPath,
1206
            pkgPath: file.pkgPath,
1207
            code: event.code,
1208
            map: event.map,
1209
            typedef: event.typedef
1210
        };
1211
    }
1212

1213
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1214
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1215
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
37✔
1216
            collection[s`${entry.src}`] = entry;
20✔
1217
            return collection;
20✔
1218
        }, {});
1219

1220
        const getOutputPath = (file: BscFile) => {
37✔
1221
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
77✔
1222
            if (!filePathObj) {
77✔
1223
                //this file has been added in-memory, from a plugin, for example
1224
                filePathObj = {
47✔
1225
                    //add an interpolated src path (since it doesn't actually exist in memory)
1226
                    src: `bsc:/${file.pkgPath}`,
1227
                    dest: file.pkgPath
1228
                };
1229
            }
1230
            //replace the file extension
1231
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
77✔
1232
            //prepend the staging folder path
1233
            outputPath = s`${stagingDir}/${outputPath}`;
77✔
1234
            return outputPath;
77✔
1235
        };
1236

1237
        const entries = Object.values(this.files)
37✔
1238
            //only include the files from fileEntries
1239
            .filter(file => !!mappedFileEntries[file.srcPath])
39✔
1240
            .map(file => {
1241
                return {
18✔
1242
                    file: file,
1243
                    outputPath: getOutputPath(file)
1244
                };
1245
            })
1246
            //sort the entries to make transpiling more deterministic
1247
            .sort((a, b) => {
1248
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1249
            });
1250

1251
        const astEditor = new AstEditor();
37✔
1252

1253
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
37✔
1254
        return {
37✔
1255
            entries: entries,
1256
            getOutputPath: getOutputPath,
1257
            astEditor: astEditor
1258
        };
1259
    }
1260

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

1264
        const processedFiles = new Set<string>();
32✔
1265

1266
        const transpileFile = async (srcPath: string, outputPath?: string) => {
32✔
1267
            //find the file in the program
1268
            const file = this.getFile(srcPath);
35✔
1269
            //mark this file as processed so we don't process it more than once
1270
            processedFiles.add(outputPath?.toLowerCase());
35!
1271

1272
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
35✔
1273
                //skip transpiling typedef files
1274
                if (isBrsFile(file) && file.isTypedef) {
34✔
1275
                    return;
1✔
1276
                }
1277

1278
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
33✔
1279

1280
                //make sure the full dir path exists
1281
                await fsExtra.ensureDir(path.dirname(outputPath));
33✔
1282

1283
                if (await fsExtra.pathExists(outputPath)) {
33!
1284
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1285
                }
1286
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, fileTranspileResult.map.toString()) : null;
33✔
1287
                await Promise.all([
33✔
1288
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1289
                    writeMapPromise
1290
                ]);
1291

1292
                if (fileTranspileResult.typedef) {
33✔
1293
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1294
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1295
                }
1296
            }
1297
        };
1298

1299
        let promises = entries.map(async (entry) => {
32✔
1300
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
12!
1301
        });
1302

1303
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1304
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
32✔
1305
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
31✔
1306
        }
1307
        await Promise.all(promises);
32✔
1308

1309
        //transpile any new files that plugins added since the start of this transpile process
1310
        do {
32✔
1311
            promises = [];
52✔
1312
            for (const key in this.files) {
52✔
1313
                const file = this.files[key];
59✔
1314
                //this is a new file
1315
                const outputPath = getOutputPath(file);
59✔
1316
                if (!processedFiles.has(outputPath?.toLowerCase())) {
59!
1317
                    promises.push(
23✔
1318
                        transpileFile(file?.srcPath, outputPath)
69!
1319
                    );
1320
                }
1321
            }
1322
            if (promises.length > 0) {
52✔
1323
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1324
                await Promise.all(promises);
20✔
1325
            }
1326
        }
1327
        while (promises.length > 0);
1328
        this.afterProgramTranspile(entries, astEditor);
32✔
1329
    }
1330

1331
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1332
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
36✔
1333
        astEditor.undoAll();
36✔
1334
    }
1335

1336
    /**
1337
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1338
     */
1339
    public findFilesForFunction(functionName: string) {
1340
        const files = [] as BscFile[];
7✔
1341
        const lowerFunctionName = functionName.toLowerCase();
7✔
1342
        //find every file with this function defined
1343
        for (const file of Object.values(this.files)) {
7✔
1344
            if (isBrsFile(file)) {
25✔
1345
                //TODO handle namespace-relative function calls
1346
                //if the file has a function with this name
1347
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
17✔
1348
                    files.push(file);
2✔
1349
                }
1350
            }
1351
        }
1352
        return files;
7✔
1353
    }
1354

1355
    /**
1356
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1357
     */
1358
    public findFilesForClass(className: string) {
1359
        const files = [] as BscFile[];
7✔
1360
        const lowerClassName = className.toLowerCase();
7✔
1361
        //find every file with this class defined
1362
        for (const file of Object.values(this.files)) {
7✔
1363
            if (isBrsFile(file)) {
25✔
1364
                //TODO handle namespace-relative classes
1365
                //if the file has a function with this name
1366
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
17✔
1367
                    files.push(file);
1✔
1368
                }
1369
            }
1370
        }
1371
        return files;
7✔
1372
    }
1373

1374
    public findFilesForNamespace(name: string) {
1375
        const files = [] as BscFile[];
7✔
1376
        const lowerName = name.toLowerCase();
7✔
1377
        //find every file with this class defined
1378
        for (const file of Object.values(this.files)) {
7✔
1379
            if (isBrsFile(file)) {
25✔
1380
                if (file.parser.references.namespaceStatements.find((x) => {
17✔
1381
                    const namespaceName = x.name.toLowerCase();
7✔
1382
                    return (
7✔
1383
                        //the namespace name matches exactly
1384
                        namespaceName === lowerName ||
9✔
1385
                        //the full namespace starts with the name (honoring the part boundary)
1386
                        namespaceName.startsWith(lowerName + '.')
1387
                    );
1388
                })) {
1389
                    files.push(file);
6✔
1390
                }
1391
            }
1392
        }
1393
        return files;
7✔
1394
    }
1395

1396
    public findFilesForEnum(name: string) {
1397
        const files = [] as BscFile[];
8✔
1398
        const lowerName = name.toLowerCase();
8✔
1399
        //find every file with this class defined
1400
        for (const file of Object.values(this.files)) {
8✔
1401
            if (isBrsFile(file)) {
26✔
1402
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
18✔
1403
                    files.push(file);
1✔
1404
                }
1405
            }
1406
        }
1407
        return files;
8✔
1408
    }
1409

1410
    private _manifest: Map<string, string>;
1411

1412
    /**
1413
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1414
     * @param parsedManifest The manifest map to read from and modify
1415
     */
1416
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1417
        // Lift the bs_consts defined in the manifest
1418
        let bsConsts = getBsConst(parsedManifest, false);
13✔
1419

1420
        // Override or delete any bs_consts defined in the bs config
1421
        for (const key in this.options?.manifest?.bs_const) {
13!
1422
            const value = this.options.manifest.bs_const[key];
3✔
1423
            if (value === null) {
3✔
1424
                bsConsts.delete(key);
1✔
1425
            } else {
1426
                bsConsts.set(key, value);
2✔
1427
            }
1428
        }
1429

1430
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1431
        let constString = '';
13✔
1432
        for (const [key, value] of bsConsts) {
13✔
1433
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
6✔
1434
        }
1435

1436
        // Set the updated bs_const value
1437
        parsedManifest.set('bs_const', constString);
13✔
1438
    }
1439

1440
    /**
1441
     * Try to find and load the manifest into memory
1442
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1443
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1444
     */
1445
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
892✔
1446
        //if we already have a manifest instance, and should not replace...then don't replace
1447
        if (!replaceIfAlreadyLoaded && this._manifest) {
898!
1448
            return;
×
1449
        }
1450
        let manifestPath = manifestFileObj
898✔
1451
            ? manifestFileObj.src
898✔
1452
            : path.join(this.options.rootDir, 'manifest');
1453

1454
        try {
898✔
1455
            // we only load this manifest once, so do it sync to improve speed downstream
1456
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
898✔
1457
            const parsedManifest = parseManifest(contents);
13✔
1458
            this.buildBsConstsIntoParsedManifest(parsedManifest);
13✔
1459
            this._manifest = parsedManifest;
13✔
1460
        } catch (e) {
1461
            this._manifest = new Map();
885✔
1462
        }
1463
    }
1464

1465
    /**
1466
     * Get a map of the manifest information
1467
     */
1468
    public getManifest() {
1469
        if (!this._manifest) {
1,193✔
1470
            this.loadManifest();
891✔
1471
        }
1472
        return this._manifest;
1,193✔
1473
    }
1474

1475
    public dispose() {
1476
        this.plugins.emit('beforeProgramDispose', { program: this });
1,157✔
1477

1478
        for (let filePath in this.files) {
1,157✔
1479
            this.files[filePath].dispose();
1,132✔
1480
        }
1481
        for (let name in this.scopes) {
1,157✔
1482
            this.scopes[name].dispose();
2,293✔
1483
        }
1484
        this.globalScope.dispose();
1,157✔
1485
        this.dependencyGraph.dispose();
1,157✔
1486
    }
1487
}
1488

1489
export interface FileTranspileResult {
1490
    srcPath: string;
1491
    pkgPath: string;
1492
    code: string;
1493
    map: SourceMapGenerator;
1494
    typedef: string;
1495
}
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

© 2025 Coveralls, Inc