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

rokucommunity / brighterscript / 28895861222

07 Jul 2026 08:18PM UTC coverage: 88.392% (-0.09%) from 88.483%
28895861222

Pull #1741

github

web-flow
Merge a27b5e573 into 1d0dd8bf7
Pull Request #1741: Add SceneGraph XML element and attribute completions

9136 of 10878 branches covered (83.99%)

Branch coverage included in aggregate %.

119 of 139 new or added lines in 3 files covered. (85.61%)

1 existing line in 1 file now uncovered.

11370 of 12321 relevant lines covered (92.28%)

2034.46 hits per line

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

90.81
/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 * as semver from 'semver';
1✔
5
import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange, InlayHint } from 'vscode-languageserver';
6
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
1✔
7
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
8
import { Scope } from './Scope';
1✔
9
import type { NamespaceContainer, NamespaceFileContribution } from './Scope';
10
import { SymbolTable } from './SymbolTable';
1✔
11
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
12
import { BrsFile } from './files/BrsFile';
1✔
13
import { XmlFile } from './files/XmlFile';
1✔
14
import type { BsDiagnostic, File, FileReference, FileObj, BscFile, SemanticToken, AfterFileTranspileEvent, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, ProvideSelectionRangesEvent, ProvideInlayHintsEvent, OnGetSourceFixAllCodeActionsEvent } from './interfaces';
15
import type { SourceFixAllCodeAction } from './CodeActionUtil';
16
import { codeActionUtil } from './CodeActionUtil';
1✔
17
import { standardizePath as s, util } from './util';
1✔
18
import { XmlScope } from './XmlScope';
1✔
19
import { DiagnosticFilterer } from './DiagnosticFilterer';
1✔
20
import { DependencyGraph } from './DependencyGraph';
1✔
21
import type { Logger } from './logging';
22
import { LogLevel, createLogger } from './logging';
1✔
23
import chalk from 'chalk';
1✔
24
import { globalFile } from './globalCallables';
1✔
25
import { parseManifest, parseManifestEntries, getBsConst } from './preprocessor/Manifest';
1✔
26
import type { ManifestEntry } from './preprocessor/Manifest';
27
import { DEFAULT_MIN_FIRMWARE_VERSION, RSG_VERSIONS } from './RokuConstants';
1✔
28
import { URI } from 'vscode-uri';
1✔
29
import PluginInterface from './PluginInterface';
1✔
30
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
1✔
31
import type { FunctionStatement, NamespaceStatement } from './parser/Statement';
32
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
33
import { AstEditor } from './astUtils/AstEditor';
1✔
34
import type { SourceMapGenerator } from 'source-map';
35
import type { Statement } from './parser/AstNode';
36
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
37
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
38
import { DiagnosticSeverityAdjuster } from './DiagnosticSeverityAdjuster';
1✔
39
import { Sequencer } from './common/Sequencer';
1✔
40
import { Deferred } from './deferred';
1✔
41
import { nodes as builtInSceneGraphNodeData } from './roku-types';
1✔
42
import type { SGNodeData } from './roku-types';
43

44
const startOfSourcePkgPath = `source${path.sep}`;
1✔
45
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
46
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
47

48
/**
49
 * The built-in Roku SceneGraph nodes, keyed by their lower-case name
50
 */
51
const builtInSceneGraphNodes = builtInSceneGraphNodeData as unknown as Record<string, SGNodeData>;
1✔
52

53
export interface SourceObj {
54
    /**
55
     * @deprecated use `srcPath` instead
56
     */
57
    pathAbsolute: string;
58
    srcPath: string;
59
    source: string;
60
    definitions?: string;
61
}
62

63
export interface TranspileObj {
64
    file: BscFile;
65
    outputPath: string;
66
}
67

68
export interface SignatureInfoObj {
69
    index: number;
70
    key: string;
71
    signature: SignatureInformation;
72
}
73

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

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

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

93
        this.createGlobalScope();
1,802✔
94
    }
95

96
    public options: FinalizedBsConfig;
97
    public logger: Logger;
98

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

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

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

123
    private diagnosticAdjuster = new DiagnosticSeverityAdjuster();
1,802✔
124

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

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

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

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

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

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

160
    public get bslibPrefix() {
161
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
456✔
162
            return 'rokucommunity_bslib';
3✔
163
        } else {
164
            return 'bslib';
453✔
165
        }
166
    }
167

168

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

175
    /**
176
     * Map from a lower-cased namespace name part to the set of `BrsFile`s that contribute
177
     * to it. Built lazily, invalidated whenever any file is added, removed, or re-parsed
178
     * (`setFile` and `removeFile` both clear it).
179
     *
180
     * Used by `ScopeNamespaceLookup` to resolve a namespace name to its contributing
181
     * files in O(1), then intersect against the scope's file set.
182
     */
183
    private namespaceContributors: Map<string, Set<BrsFile>> | undefined;
184

185
    /**
186
     * Look up the set of `BrsFile`s that declare any part of the given namespace name
187
     * (lowercased). Returns `undefined` when no file contributes.
188
     * @internal
189
     */
190
    protected getNamespaceContributors(namespaceNameLower: string): Set<BrsFile> | undefined {
191
        if (!this.namespaceContributors) {
333✔
192
            this.namespaceContributors = this.buildNamespaceContributors();
201✔
193
        }
194
        return this.namespaceContributors.get(namespaceNameLower);
333✔
195
    }
196

197
    private buildNamespaceContributors(): Map<string, Set<BrsFile>> {
198
        const contributors = new Map<string, Set<BrsFile>>();
201✔
199
        for (const file of Object.values(this.files)) {
201✔
200
            if (isBrsFile(file)) {
275✔
201
                // eslint-disable-next-line @typescript-eslint/dot-notation
202
                for (const nameLower of file['getNamespaceContributions']().keys()) {
257✔
203
                    let set = contributors.get(nameLower);
358✔
204
                    if (!set) {
358✔
205
                        set = new Set<BrsFile>();
330✔
206
                        contributors.set(nameLower, set);
330✔
207
                    }
208
                    set.add(file);
358✔
209
                }
210
            }
211
        }
212
        return contributors;
201✔
213
    }
214

215
    /**
216
     * Cached slow-path namespace aggregates, keyed by `(nameLower, sorted-contributor-pkgPaths)`.
217
     * Two scopes with the same in-scope file set for a multi-contributor namespace share
218
     * the same aggregate object (and therefore the same merged statement collections and
219
     * symbolTable instance). Built lazily, invalidated alongside `namespaceContributors`.
220
     *
221
     * The aggregate is stored as a `NamespaceContainer` whose `namespaces` field is an
222
     * empty Map: scopes always wrap the aggregate before returning to plugins, and the
223
     * wrapper supplies its own scope-filtered children. Plugins never see the aggregate
224
     * directly.
225
     */
226
    private aggregateNamespaceContainerCache: Map<string, NamespaceContainer> | undefined;
227

228
    /**
229
     * Get or build the shared aggregate for a namespace whose in-scope contributors
230
     * include more than one file. The aggregate's heavy fields are computed once per
231
     * unique `(nameLower, contributing-files-set)` and reused across every scope that
232
     * sees the same set.
233
     * @internal
234
     */
235
    protected getAggregateNamespaceContainer(nameLower: string, contributions: NamespaceFileContribution[]): NamespaceContainer {
236
        if (!this.aggregateNamespaceContainerCache) {
33✔
237
            this.aggregateNamespaceContainerCache = new Map<string, NamespaceContainer>();
22✔
238
        }
239
        //sorted pkgPaths ensure two scopes with the same contributor set hit the same key
240
        const key = nameLower + '|' + contributions
33✔
241
            .map(c => c.file.pkgPath.toLowerCase())
69✔
242
            .sort()
243
            .join('|');
244
        let aggregate = this.aggregateNamespaceContainerCache.get(key);
33✔
245
        if (!aggregate) {
33✔
246
            aggregate = this.buildAggregateNamespaceContainer(contributions);
26✔
247
            this.aggregateNamespaceContainerCache.set(key, aggregate);
26✔
248
        }
249
        return aggregate;
33✔
250
    }
251

252
    private buildAggregateNamespaceContainer(contributions: NamespaceFileContribution[]): NamespaceContainer {
253
        const first = contributions[0];
26✔
254
        //field order matches the NamespaceContainer interface declaration so aggregates
255
        //share a single V8 hidden class with the per-scope wrapper containers
256
        const aggregate: NamespaceContainer = {
26✔
257
            file: first.file,
258
            fullName: first.fullName,
259
            nameRange: first.nameRange,
260
            lastPartName: first.lastPartName,
261
            namespaces: new Map(),
262
            statements: undefined,
263
            classStatements: undefined,
264
            functionStatements: undefined,
265
            enumStatements: undefined,
266
            constStatements: undefined,
267
            symbolTable: undefined
268
        };
269
        for (const contribution of contributions) {
26✔
270
            if (contribution.statements?.length) {
55✔
271
                (aggregate.statements ??= []).push(...contribution.statements);
45✔
272
            }
273
            if (contribution.classStatements) {
55✔
274
                aggregate.classStatements = { ...(aggregate.classStatements ?? {}), ...contribution.classStatements };
7✔
275
            }
276
            if (contribution.functionStatements) {
55✔
277
                aggregate.functionStatements = { ...(aggregate.functionStatements ?? {}), ...contribution.functionStatements };
29✔
278
            }
279
            if (contribution.enumStatements) {
55✔
280
                aggregate.enumStatements ??= new Map();
5!
281
                for (const [key, value] of contribution.enumStatements) {
5✔
282
                    aggregate.enumStatements.set(key, value);
5✔
283
                }
284
            }
285
            if (contribution.constStatements) {
55✔
286
                aggregate.constStatements ??= new Map();
6✔
287
                for (const [key, value] of contribution.constStatements) {
6✔
288
                    aggregate.constStatements.set(key, value);
7✔
289
                }
290
            }
291
            if (contribution.symbolTable) {
55✔
292
                aggregate.symbolTable ??= new SymbolTable(`Namespace Multi-File Aggregate: '${first.fullName}'`);
45✔
293
                aggregate.symbolTable.mergeSymbolTable(contribution.symbolTable);
45✔
294
            }
295
        }
296
        return aggregate;
26✔
297
    }
298

299
    /**
300
     * Invalidate the program-level namespace contributors map and the slow-path aggregate
301
     * cache. Called by `setFile` and `removeFile`; downstream scope namespace lookups
302
     * already rebuild via the dependency-graph invalidation chain, so this only needs
303
     * to drop the cached maps.
304
     */
305
    private invalidateNamespaceContributorCache() {
306
        this.namespaceContributors = undefined;
1,945✔
307
        this.aggregateNamespaceContainerCache = undefined;
1,945✔
308
    }
309

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

312
    protected addScope(scope: Scope) {
313
        this.scopes[scope.name] = scope;
1,626✔
314
    }
315

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

324
    /**
325
     * Get the component with the specified name
326
     */
327
    public getComponent(componentName: string) {
328
        if (componentName) {
787✔
329
            //return the first compoment in the list with this name
330
            //(components are ordered in this list by pkgPath to ensure consistency)
331
            return this.components[componentName.toLowerCase()]?.[0];
737✔
332
        } else {
333
            return undefined;
50✔
334
        }
335
    }
336

337
    /**
338
     * Register (or replace) the reference to a component in the component map
339
     */
340
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
341
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
288✔
342
        if (!this.components[key]) {
288✔
343
            this.components[key] = [];
276✔
344
        }
345
        this.components[key].push({
288✔
346
            file: xmlFile,
347
            scope: scope
348
        });
349
        this.components[key].sort((a, b) => {
288✔
350
            const pathA = a.file.pkgPath.toLowerCase();
5✔
351
            const pathB = b.file.pkgPath.toLowerCase();
5✔
352
            if (pathA < pathB) {
5✔
353
                return -1;
1✔
354
            } else if (pathA > pathB) {
4!
355
                return 1;
4✔
356
            }
357
            return 0;
×
358
        });
359
        this.syncComponentDependencyGraph(this.components[key]);
288✔
360
    }
361

362
    /**
363
     * Remove the specified component from the components map
364
     */
365
    private unregisterComponent(xmlFile: XmlFile) {
366
        const key = (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
11✔
367
        const arr = this.components[key] || [];
11!
368
        for (let i = 0; i < arr.length; i++) {
11✔
369
            if (arr[i].file === xmlFile) {
11!
370
                arr.splice(i, 1);
11✔
371
                break;
11✔
372
            }
373
        }
374
        this.syncComponentDependencyGraph(arr);
11✔
375
    }
376

377
    /**
378
     * re-attach the dependency graph with a new key for any component who changed
379
     * their position in their own named array (only matters when there are multiple
380
     * components with the same name)
381
     */
382
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
383
        //reattach every dependency graph
384
        for (let i = 0; i < components.length; i++) {
299✔
385
            const { file, scope } = components[i];
294✔
386

387
            //attach (or re-attach) the dependencyGraph for every component whose position changed
388
            if (file.dependencyGraphIndex !== i) {
294✔
389
                file.dependencyGraphIndex = i;
290✔
390
                file.attachDependencyGraph(this.dependencyGraph);
290✔
391
                scope.attachDependencyGraph(this.dependencyGraph);
290✔
392
            }
393
        }
394
    }
395

396
    /**
397
     * Get a list of all files that are included in the project but are not referenced
398
     * by any scope in the program.
399
     */
400
    public getUnreferencedFiles() {
401
        let result = [] as File[];
1,165✔
402
        for (let filePath in this.files) {
1,165✔
403
            let file = this.files[filePath];
1,385✔
404
            //is this file part of a scope
405
            if (!this.getFirstScopeForFile(file)) {
1,385✔
406
                //no scopes reference this file. add it to the list
407
                result.push(file);
46✔
408
            }
409
        }
410
        return result;
1,165✔
411
    }
412

413
    /**
414
     * Get the list of errors for the entire program. It's calculated on the fly
415
     * by walking through every file, so call this sparingly.
416
     */
417
    public getDiagnostics() {
418
        return this.logger.time(LogLevel.info, ['Program.getDiagnostics()'], () => {
1,165✔
419

420
            let diagnostics = [...this.diagnostics];
1,165✔
421

422
            //get the diagnostics from all scopes
423
            for (let scopeName in this.scopes) {
1,165✔
424
                let scope = this.scopes[scopeName];
2,332✔
425
                diagnostics.push(
2,332✔
426
                    ...scope.getDiagnostics()
427
                );
428
            }
429

430
            //get the diagnostics from all unreferenced files
431
            let unreferencedFiles = this.getUnreferencedFiles();
1,165✔
432
            for (let file of unreferencedFiles) {
1,165✔
433
                diagnostics.push(
46✔
434
                    ...file.getDiagnostics()
435
                );
436
            }
437
            const filteredDiagnostics = this.logger.time(LogLevel.debug, ['filter diagnostics'], () => {
1,165✔
438
                //filter out diagnostics based on our diagnostic filters
439
                let finalDiagnostics = this.diagnosticFilterer.filter({
1,165✔
440
                    ...this.options,
441
                    rootDir: this.options.rootDir
442
                }, diagnostics);
443
                return finalDiagnostics;
1,165✔
444
            });
445

446
            this.logger.time(LogLevel.debug, ['adjust diagnostics severity'], () => {
1,165✔
447
                this.diagnosticAdjuster.adjust(this.options, diagnostics);
1,165✔
448
            });
449

450
            this.logger.info(`diagnostic counts: total=${chalk.yellow(diagnostics.length.toString())}, after filter=${chalk.yellow(filteredDiagnostics.length.toString())}`);
1,165✔
451
            return filteredDiagnostics;
1,165✔
452
        });
453
    }
454

455
    public addDiagnostics(diagnostics: BsDiagnostic[]) {
456
        this.diagnostics.push(...diagnostics);
55✔
457
    }
458

459
    /**
460
     * Determine if the specified file is loaded in this program right now.
461
     * @param filePath the absolute or relative path to the file
462
     * @param normalizePath should the provided path be normalized before use
463
     */
464
    public hasFile(filePath: string, normalizePath = true) {
1,827✔
465
        return !!this.getFile(filePath, normalizePath);
1,827✔
466
    }
467

468
    public getPkgPath(...args: any[]): any { //eslint-disable-line
469
        throw new Error('Not implemented');
×
470
    }
471

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

486
    /**
487
     * Return all scopes
488
     */
489
    public getScopes() {
490
        return Object.values(this.scopes);
9✔
491
    }
492

493
    /**
494
     * Find the scope for the specified component
495
     */
496
    public getComponentScope(componentName: string) {
497
        return this.getComponent(componentName)?.scope;
197✔
498
    }
499

500
    /**
501
     * Get the names of all known SceneGraph nodes: the built-in Roku nodes plus every component
502
     * defined in this program. Names keep their original casing and are deduplicated by lower-case name.
503
     */
504
    public getSceneGraphNodeNames(): string[] {
505
        const namesByLowerName = new Map<string, string>();
2✔
506
        for (const node of Object.values(builtInSceneGraphNodes)) {
2✔
507
            namesByLowerName.set(node.name.toLowerCase(), node.name);
194✔
508
        }
509
        for (const componentName in this.components) {
2✔
510
            const displayName = this.components[componentName][0]?.file.componentName?.text;
3!
511
            if (displayName) {
3!
512
                namesByLowerName.set(displayName.toLowerCase(), displayName);
3✔
513
            }
514
        }
515
        return [...namesByLowerName.values()];
2✔
516
    }
517

518
    /**
519
     * Determine whether a SceneGraph node with the given name exists, either as a built-in Roku node
520
     * or as a component defined in this program.
521
     */
522
    public hasSceneGraphNode(nodeName: string): boolean {
523
        if (!nodeName) {
4!
NEW
524
            return false;
×
525
        }
526
        return !!builtInSceneGraphNodes[nodeName.toLowerCase()] || !!this.getComponent(nodeName);
4✔
527
    }
528

529
    /**
530
     * Get the built-in Roku node data and/or the project component file backing a SceneGraph node name.
531
     * Returns `undefined` when no node or component matches.
532
     */
533
    public getSceneGraphNode(nodeName: string): SceneGraphNodeLookup | undefined {
NEW
534
        if (!nodeName) {
×
NEW
535
            return undefined;
×
536
        }
NEW
537
        const builtInNode = builtInSceneGraphNodes[nodeName.toLowerCase()];
×
NEW
538
        const componentFile = this.getComponent(nodeName)?.file;
×
NEW
539
        if (!builtInNode && !componentFile) {
×
NEW
540
            return undefined;
×
541
        }
NEW
542
        return { builtInNode: builtInNode, componentFile: componentFile };
×
543
    }
544

545
    /**
546
     * Get every field available on a SceneGraph node, walking the full `extends` chain across both
547
     * built-in Roku nodes and project components. Fields declared closer to the node take precedence
548
     * over inherited fields with the same name.
549
     */
550
    public getSceneGraphNodeFields(nodeName: string): ResolvedSceneGraphField[] {
551
        const fieldsByLowerName = new Map<string, ResolvedSceneGraphField>();
3✔
552
        const visitedNodeNames = new Set<string>();
3✔
553

554
        const addField = (field: ResolvedSceneGraphField) => {
3✔
555
            if (!field.name) {
95!
NEW
556
                return;
×
557
            }
558
            const lowerName = field.name.toLowerCase();
95✔
559
            //the first definition we encounter is the closest one, so it wins
560
            if (!fieldsByLowerName.has(lowerName)) {
95!
561
                fieldsByLowerName.set(lowerName, field);
95✔
562
            }
563
        };
564

565
        const walk = (currentNodeName: string, origin: SceneGraphFieldOrigin) => {
3✔
566
            const lowerName = currentNodeName?.toLowerCase();
11!
567
            if (!lowerName || visitedNodeNames.has(lowerName)) {
11!
NEW
568
                return;
×
569
            }
570
            visitedNodeNames.add(lowerName);
11✔
571

572
            //prefer a project component (a project component can shadow a built-in of the same name)
573
            const component = this.getComponent(currentNodeName);
11✔
574
            if (component) {
11✔
575
                for (const field of component.file.ast.component?.api?.fields ?? []) {
1!
576
                    addField({ name: field.id, type: field.type, default: field.value, origin: origin });
1✔
577
                }
578
                const parentName = component.file.ast.component?.extends;
1!
579
                if (parentName) {
1!
580
                    walk(parentName, 'inherited');
1✔
581
                }
582
                return;
1✔
583
            }
584

585
            const builtInNode = builtInSceneGraphNodes[lowerName];
10✔
586
            if (builtInNode) {
10!
587
                for (const field of builtInNode.fields ?? []) {
10!
588
                    addField({
94✔
589
                        name: field.name,
590
                        type: field.type,
591
                        default: field.default,
592
                        description: field.description,
593
                        accessPermission: field.accessPermission,
594
                        origin: origin
595
                    });
596
                }
597
                if (builtInNode.extends?.name) {
10✔
598
                    walk(builtInNode.extends.name, 'inherited');
7✔
599
                }
600
            }
601
        };
602

603
        walk(nodeName, 'own');
3✔
604
        return [...fieldsByLowerName.values()];
3✔
605
    }
606

607
    /**
608
     * Update internal maps with this file reference
609
     */
610
    private assignFile<T extends BscFile = BscFile>(file: T) {
611
        this.files[file.srcPath.toLowerCase()] = file;
1,785✔
612
        this.pkgMap[file.pkgPath.toLowerCase()] = file;
1,785✔
613
        return file;
1,785✔
614
    }
615

616
    /**
617
     * Remove this file from internal maps
618
     */
619
    private unassignFile<T extends BscFile = BscFile>(file: T) {
620
        delete this.files[file.srcPath.toLowerCase()];
156✔
621
        delete this.pkgMap[file.pkgPath.toLowerCase()];
156✔
622
        return file;
156✔
623
    }
624

625
    /**
626
     * Load a file into the program. If that file already exists, it is replaced.
627
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
628
     * @param srcPath the file path relative to the root dir
629
     * @param fileContents the file contents
630
     * @deprecated use `setFile` instead
631
     */
632
    public addOrReplaceFile<T extends BscFile>(srcPath: string, fileContents: string): T;
633
    /**
634
     * Load a file into the program. If that file already exists, it is replaced.
635
     * @param fileEntry an object that specifies src and dest for the file.
636
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
637
     * @deprecated use `setFile` instead
638
     */
639
    public addOrReplaceFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
640
    public addOrReplaceFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
641
        return this.setFile<T>(fileParam as any, fileContents);
1✔
642
    }
643

644
    /**
645
     * Load a file into the program. If that file already exists, it is replaced.
646
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
647
     * @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:/`)
648
     * @param fileContents the file contents
649
     */
650
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileContents: string): T;
651
    /**
652
     * Load a file into the program. If that file already exists, it is replaced.
653
     * @param fileEntry an object that specifies src and dest for the file.
654
     * @param fileContents the file contents. If not provided, the file will be loaded from disk
655
     */
656
    public setFile<T extends BscFile>(fileEntry: FileObj, fileContents: string): T;
657
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileContents: string): T {
658
        //normalize the file paths
659
        const { srcPath, pkgPath } = this.getPaths(fileParam, this.options.rootDir);
1,789✔
660

661
        //namespace contributions for the new/replaced file may differ; force the
662
        //program-level contributors map to rebuild on next query
663
        this.invalidateNamespaceContributorCache();
1,789✔
664

665
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
1,789✔
666
            //if the file is already loaded, remove it
667
            if (this.hasFile(srcPath)) {
1,789✔
668
                this.removeFile(srcPath);
139✔
669
            }
670
            let fileExtension = path.extname(srcPath).toLowerCase();
1,789✔
671
            let file: BscFile | undefined;
672

673
            if (fileExtension === '.brs' || fileExtension === '.bs') {
1,789✔
674
                //add the file to the program
675
                const brsFile = this.assignFile(
1,497✔
676
                    new BrsFile(srcPath, pkgPath, this)
677
                );
678

679
                //add file to the `source` dependency list
680
                if (brsFile.pkgPath.startsWith(startOfSourcePkgPath)) {
1,497✔
681
                    this.createSourceScope();
1,307✔
682
                    this.dependencyGraph.addDependency('scope:source', brsFile.dependencyGraphKey);
1,307✔
683
                }
684

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

693
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
1,497✔
694
                    brsFile.parse(sourceObj.source);
1,497✔
695
                });
696

697
                //notify plugins that this file has finished parsing
698
                this.plugins.emit('afterFileParse', brsFile);
1,497✔
699

700
                file = brsFile;
1,497✔
701

702
                brsFile.attachDependencyGraph(this.dependencyGraph);
1,497✔
703

704
            } else if (
292✔
705
                //is xml file
706
                fileExtension === '.xml' &&
583✔
707
                //resides in the components folder (Roku will only parse xml files in the components folder)
708
                pkgPath.toLowerCase().startsWith(util.pathSepNormalize(`components/`))
709
            ) {
710
                //add the file to the program
711
                const xmlFile = this.assignFile(
288✔
712
                    new XmlFile(srcPath, pkgPath, this)
713
                );
714

715
                let sourceObj: SourceObj = {
288✔
716
                    //TODO remove `pathAbsolute` in v1
717
                    pathAbsolute: srcPath,
718
                    srcPath: srcPath,
719
                    source: fileContents
720
                };
721
                this.plugins.emit('beforeFileParse', sourceObj);
288✔
722

723
                this.logger.time(LogLevel.debug, ['parse', chalk.green(srcPath)], () => {
288✔
724
                    xmlFile.parse(sourceObj.source);
288✔
725
                });
726

727
                //notify plugins that this file has finished parsing
728
                this.plugins.emit('afterFileParse', xmlFile);
288✔
729

730
                file = xmlFile;
288✔
731

732
                //create a new scope for this xml file
733
                let scope = new XmlScope(xmlFile, this);
288✔
734
                this.addScope(scope);
288✔
735

736
                //register this compoent now that we have parsed it and know its component name
737
                this.registerComponent(xmlFile, scope);
288✔
738

739
                //notify plugins that the scope is created and the component is registered
740
                this.plugins.emit('afterScopeCreate', scope);
288✔
741
            } else {
742
                //TODO do we actually need to implement this? Figure out how to handle img paths
743
                // let genericFile = this.files[srcPath] = <any>{
744
                //     srcPath: srcPath,
745
                //     pkgPath: pkgPath,
746
                //     wasProcessed: true
747
                // } as File;
748
                // file = <any>genericFile;
749
            }
750
            return file;
1,789✔
751
        });
752
        return file as T;
1,789✔
753
    }
754

755
    /**
756
     * Given a srcPath, a pkgPath, or both, resolve whichever is missing, relative to rootDir.
757
     * @param fileParam an object representing file paths
758
     * @param rootDir must be a pre-normalized path
759
     */
760
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
761
        let srcPath: string | undefined;
762
        let pkgPath: string | undefined;
763

764
        assert.ok(fileParam, 'fileParam is required');
1,796✔
765

766
        //lift the srcPath and pkgPath vars from the incoming param
767
        if (typeof fileParam === 'string') {
1,796✔
768
            fileParam = this.removePkgPrefix(fileParam);
1,302✔
769
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
1,302✔
770
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1,302✔
771
        } else {
772
            let param: any = fileParam;
494✔
773

774
            if (param.src) {
494✔
775
                srcPath = s`${param.src}`;
491✔
776
            }
777
            if (param.srcPath) {
494✔
778
                srcPath = s`${param.srcPath}`;
2✔
779
            }
780
            if (param.dest) {
494✔
781
                pkgPath = s`${this.removePkgPrefix(param.dest)}`;
491✔
782
            }
783
            if (param.pkgPath) {
494✔
784
                pkgPath = s`${this.removePkgPrefix(param.pkgPath)}`;
2✔
785
            }
786
        }
787

788
        //if there's no srcPath, use the pkgPath to build an absolute srcPath
789
        if (!srcPath) {
1,796✔
790
            srcPath = s`${rootDir}/${pkgPath}`;
1✔
791
        }
792
        //coerce srcPath to an absolute path
793
        if (!path.isAbsolute(srcPath)) {
1,796✔
794
            srcPath = util.standardizePath(srcPath);
1✔
795
        }
796

797
        //if there's no pkgPath, compute relative path from rootDir
798
        if (!pkgPath) {
1,796✔
799
            pkgPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
800
        }
801

802
        assert.ok(srcPath, 'fileEntry.src is required');
1,796✔
803
        assert.ok(pkgPath, 'fileEntry.dest is required');
1,796✔
804

805
        return {
1,796✔
806
            srcPath: srcPath,
807
            //remove leading slash from pkgPath
808
            pkgPath: pkgPath.replace(/^[\/\\]+/, '')
809
        };
810
    }
811

812
    /**
813
     * Remove any leading `pkg:/` found in the path
814
     */
815
    private removePkgPrefix(path: string) {
816
        return path.replace(/^pkg:\//i, '');
1,795✔
817
    }
818

819
    /**
820
     * Ensure source scope is created.
821
     * Note: automatically called internally, and no-op if it exists already.
822
     */
823
    public createSourceScope() {
824
        if (!this.scopes.source) {
1,791✔
825
            const sourceScope = new Scope('source', this, 'scope:source');
1,338✔
826
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,338✔
827
            this.addScope(sourceScope);
1,338✔
828
            this.plugins.emit('afterScopeCreate', sourceScope);
1,338✔
829
        }
830
    }
831

832
    /**
833
     * Find the file by its absolute path. This is case INSENSITIVE, since
834
     * Roku is a case insensitive file system. It is an error to have multiple files
835
     * with the same path with only case being different.
836
     * @param srcPath the absolute path to the file
837
     * @deprecated use `getFile` instead, which auto-detects the path type
838
     */
839
    public getFileByPathAbsolute<T extends BrsFile | XmlFile>(srcPath: string) {
840
        srcPath = s`${srcPath}`;
×
841
        for (let filePath in this.files) {
×
842
            if (filePath.toLowerCase() === srcPath.toLowerCase()) {
×
843
                return this.files[filePath] as T;
×
844
            }
845
        }
846
    }
847

848
    /**
849
     * Get a list of files for the given (platform-normalized) pkgPath array.
850
     * Missing files are just ignored.
851
     * @deprecated use `getFiles` instead, which auto-detects the path types
852
     */
853
    public getFilesByPkgPaths<T extends BscFile[]>(pkgPaths: string[]) {
854
        return pkgPaths
×
855
            .map(pkgPath => this.getFileByPkgPath(pkgPath))
×
856
            .filter(file => file !== undefined) as T;
×
857
    }
858

859
    /**
860
     * Get a file with the specified (platform-normalized) pkg path.
861
     * If not found, return undefined
862
     * @deprecated use `getFile` instead, which auto-detects the path type
863
     */
864
    public getFileByPkgPath<T extends BscFile>(pkgPath: string) {
865
        return this.pkgMap[pkgPath.toLowerCase()] as T;
500✔
866
    }
867

868
    /**
869
     * Remove a set of files from the program
870
     * @param srcPaths can be an array of srcPath or destPath strings
871
     * @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
872
     */
873
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
874
        for (let srcPath of srcPaths) {
1✔
875
            this.removeFile(srcPath, normalizePath);
1✔
876
        }
877
    }
878

879
    /**
880
     * Remove a file from the program
881
     * @param filePath can be a srcPath, a pkgPath, or a destPath (same as pkgPath but without `pkg:/`)
882
     * @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
883
     */
884
    public removeFile(filePath: string, normalizePath = true) {
152✔
885
        this.logger.debug('Program.removeFile()', filePath);
156✔
886

887
        //namespace contributions may have included this file; force the program-level
888
        //contributors map to rebuild on next query
889
        this.invalidateNamespaceContributorCache();
156✔
890

891
        let file = this.getFile(filePath, normalizePath);
156✔
892
        if (file) {
156!
893
            this.plugins.emit('beforeFileDispose', file);
156✔
894

895
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
896
            let scope = this.scopes[file.pkgPath];
156✔
897
            if (scope) {
156✔
898
                this.plugins.emit('beforeScopeDispose', scope);
11✔
899
                scope.dispose();
11✔
900
                //notify dependencies of this scope that it has been removed
901
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
902
                delete this.scopes[file.pkgPath];
11✔
903
                this.plugins.emit('afterScopeDispose', scope);
11✔
904
            }
905
            //remove the file from the program
906
            this.unassignFile(file);
156✔
907

908
            this.dependencyGraph.remove(file.dependencyGraphKey);
156✔
909

910
            //if this is a pkg:/source file, notify the `source` scope that it has changed
911
            if (file.pkgPath.startsWith(startOfSourcePkgPath)) {
156✔
912
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
105✔
913
            }
914

915
            //if this is a component, remove it from our components map
916
            if (isXmlFile(file)) {
156✔
917
                this.unregisterComponent(file);
11✔
918
            }
919
            //dispose file
920
            file?.dispose();
156!
921
            this.plugins.emit('afterFileDispose', file);
156✔
922
        }
923
    }
924

925
    /**
926
     * Counter used to track which validation run is being logged
927
     */
928
    private validationRunSequence = 1;
1,802✔
929

930
    /**
931
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
932
     */
933
    private validationMinSyncDuration = 75;
1,802✔
934

935
    private validatePromise: Promise<void> | undefined;
936

937
    /**
938
     * Traverse the entire project, and validate all scopes
939
     */
940
    public validate(): void;
941
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
942
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
943
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
944
        const validationRunId = this.validationRunSequence++;
1,122✔
945
        const timeEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
1,122!
946

947
        let previousValidationPromise = this.validatePromise;
1,122✔
948
        const deferred = new Deferred();
1,122✔
949

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

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

961
        if (options?.async) {
1,122✔
962
            //we're async, so create a new promise chain to resolve after this validation is done
963
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
164✔
964
                return deferred.promise;
164✔
965
            });
966

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

972
        const sequencer = new Sequencer({
1,122✔
973
            name: 'program.validate',
974
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
6,732✔
975
            minSyncDuration: this.validationMinSyncDuration
976
        });
977

978
        let beforeProgramValidateWasEmitted = false;
1,122✔
979

980
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
981
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
982
        sequencer
1,122✔
983
            .once(() => {
984
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
985
                if (options?.async) {
1,122✔
986
                    return previousValidationPromise;
164✔
987
                }
988
            })
989
            .once(() => {
990
                this.diagnostics = [];
1,119✔
991
                this.plugins.emit('beforeProgramValidate', this);
1,119✔
992
                beforeProgramValidateWasEmitted = true;
1,119✔
993
            })
994
            .forEach(() => Object.values(this.files), (file) => {
1,119✔
995
                if (!file.isValidated) {
1,385✔
996
                    this.plugins.emit('beforeFileValidate', {
1,322✔
997
                        program: this,
998
                        file: file
999
                    });
1000

1001
                    //emit an event to allow plugins to contribute to the file validation process
1002
                    this.plugins.emit('onFileValidate', {
1,322✔
1003
                        program: this,
1004
                        file: file
1005
                    });
1006
                    //call file.validate() IF the file has that function defined
1007
                    file.validate?.();
1,322!
1008
                    file.isValidated = true;
1,321✔
1009

1010
                    this.plugins.emit('afterFileValidate', file);
1,321✔
1011
                }
1012
            })
1013
            .forEach(Object.values(this.scopes), (scope) => {
1014
                scope.linkSymbolTable();
2,300✔
1015
                scope.validate();
2,300✔
1016
                scope.unlinkSymbolTable();
2,298✔
1017
            })
1018
            .once(() => {
1019
                this.detectDuplicateComponentNames();
1,114✔
1020
            })
1021
            .onCancel(() => {
1022
                timeEnd('cancelled');
8✔
1023
            })
1024
            .onSuccess(() => {
1025
                timeEnd();
1,114✔
1026
            })
1027
            .onComplete(() => {
1028
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
1029
                if (beforeProgramValidateWasEmitted) {
1,122✔
1030
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
1,119✔
1031
                    this.plugins.emit('afterProgramValidate', this, wasCancelled);
1,119✔
1032
                }
1033

1034
                //regardless of the success of the validation, mark this run as complete
1035
                deferred.resolve();
1,122✔
1036
                //clear the validatePromise which means we're no longer running a validation
1037
                this.validatePromise = undefined;
1,122✔
1038
            });
1039

1040
        //run the sequencer in async mode if enabled
1041
        if (options?.async) {
1,122✔
1042
            return sequencer.run();
164✔
1043

1044
            //run the sequencer in sync mode
1045
        } else {
1046
            return sequencer.runSync();
958✔
1047
        }
1048
    }
1049

1050
    /**
1051
     * Flag all duplicate component names
1052
     */
1053
    private detectDuplicateComponentNames() {
1054
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,114✔
1055
            const file = this.files[filePath];
1,376✔
1056
            //if this is an XmlFile, and it has a valid `componentName` property
1057
            if (isXmlFile(file) && file.componentName?.text) {
1,376✔
1058
                let lowerName = file.componentName.text.toLowerCase();
245✔
1059
                if (!map[lowerName]) {
245✔
1060
                    map[lowerName] = [];
242✔
1061
                }
1062
                map[lowerName].push(file);
245✔
1063
            }
1064
            return map;
1,376✔
1065
        }, {});
1066

1067
        for (let name in componentsByName) {
1,114✔
1068
            const xmlFiles = componentsByName[name];
242✔
1069
            //add diagnostics for every duplicate component with this name
1070
            if (xmlFiles.length > 1) {
242✔
1071
                for (let xmlFile of xmlFiles) {
3✔
1072
                    const { componentName } = xmlFile;
6✔
1073
                    this.diagnostics.push({
6✔
1074
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1075
                        range: xmlFile.componentName.range,
1076
                        file: xmlFile,
1077
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1078
                            return {
6✔
1079
                                location: util.createLocation(
1080
                                    URI.file(x.srcPath ?? xmlFile.srcPath).toString(),
18!
1081
                                    x.componentName.range
1082
                                ),
1083
                                message: 'Also defined here'
1084
                            };
1085
                        })
1086
                    });
1087
                }
1088
            }
1089
        }
1090
    }
1091

1092
    /**
1093
     * Get the files for a list of filePaths
1094
     * @param filePaths can be an array of srcPath or a destPath strings
1095
     * @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
1096
     */
1097
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
28✔
1098
        return filePaths
28✔
1099
            .map(filePath => this.getFile(filePath, normalizePath))
30✔
1100
            .filter(file => file !== undefined) as T[];
30✔
1101
    }
1102

1103
    /**
1104
     * Get the file at the given path
1105
     * @param filePath can be a srcPath or a destPath
1106
     * @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
1107
     */
1108
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
7,304✔
1109
        if (typeof filePath !== 'string') {
9,317✔
1110
            return undefined;
1,976✔
1111
        } else if (path.isAbsolute(filePath)) {
7,341✔
1112
            return this.files[
3,848✔
1113
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,848✔
1114
            ] as T;
1115
        } else {
1116
            return this.pkgMap[
3,493✔
1117
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
3,493!
1118
            ] as T;
1119
        }
1120
    }
1121

1122
    /**
1123
     * Get a list of all scopes the file is loaded into
1124
     * @param file the file
1125
     */
1126
    public getScopesForFile(file: XmlFile | BrsFile | string) {
1127

1128
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
633✔
1129

1130
        let result = [] as Scope[];
633✔
1131
        if (resolvedFile) {
633✔
1132
            for (let key in this.scopes) {
632✔
1133
                let scope = this.scopes[key];
1,314✔
1134

1135
                if (scope.hasFile(resolvedFile)) {
1,314✔
1136
                    result.push(scope);
643✔
1137
                }
1138
            }
1139
        }
1140
        return result;
633✔
1141
    }
1142

1143
    /**
1144
     * Get the first found scope for a file.
1145
     */
1146
    public getFirstScopeForFile(file: XmlFile | BrsFile): Scope | undefined {
1147
        for (let key in this.scopes) {
3,073✔
1148
            let scope = this.scopes[key];
7,501✔
1149

1150
            if (scope.hasFile(file)) {
7,501✔
1151
                return scope;
2,931✔
1152
            }
1153
        }
1154
    }
1155

1156
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1157
        let results = new Map<Statement, FileLink<Statement>>();
46✔
1158
        const filesSearched = new Set<BrsFile>();
46✔
1159
        let lowerNamespaceName = namespaceName?.toLowerCase();
46✔
1160
        let lowerName = name?.toLowerCase();
46!
1161
        //look through all files in scope for matches
1162
        for (const scope of this.getScopesForFile(originFile)) {
46✔
1163
            for (const file of scope.getAllFiles()) {
46✔
1164
                if (isXmlFile(file) || filesSearched.has(file)) {
52✔
1165
                    continue;
3✔
1166
                }
1167
                filesSearched.add(file);
49✔
1168

1169
                for (const statement of [...file.parser.references.functionStatements, ...file.parser.references.classStatements.flatMap((cs) => cs.methods)]) {
49✔
1170
                    let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
111✔
1171
                    if (statement.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
111✔
1172
                        if (!results.has(statement)) {
42!
1173
                            results.set(statement, { item: statement, file: file });
42✔
1174
                        }
1175
                    }
1176
                }
1177
            }
1178
        }
1179
        return [...results.values()];
46✔
1180
    }
1181

1182
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1183
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1184
        const filesSearched = new Set<BrsFile>();
8✔
1185

1186
        //get all function names for the xml file and parents
1187
        let funcNames = new Set<string>();
8✔
1188
        let currentScope = scope;
8✔
1189
        while (isXmlScope(currentScope)) {
8✔
1190
            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
14✔
1191
                if (!filterName || name === filterName) {
14!
1192
                    funcNames.add(name);
14✔
1193
                }
1194
            }
1195
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1196
        }
1197

1198
        //look through all files in scope for matches
1199
        for (const file of scope.getOwnFiles()) {
8✔
1200
            if (isXmlFile(file) || filesSearched.has(file)) {
16✔
1201
                continue;
8✔
1202
            }
1203
            filesSearched.add(file);
8✔
1204

1205
            for (const statement of file.parser.references.functionStatements) {
8✔
1206
                if (funcNames.has(statement.name.text)) {
13!
1207
                    if (!results.has(statement)) {
13!
1208
                        results.set(statement, { item: statement, file: file });
13✔
1209
                    }
1210
                }
1211
            }
1212
        }
1213
        return [...results.values()];
8✔
1214
    }
1215

1216
    /**
1217
     * Find all available completion items at the given position
1218
     * @param filePath can be a srcPath or a destPath
1219
     * @param position the position (line & column) where completions should be found
1220
     */
1221
    public getCompletions(filePath: string, position: Position) {
1222
        let file = this.getFile(filePath);
77✔
1223
        if (!file) {
77!
1224
            return [];
×
1225
        }
1226

1227
        //find the scopes for this file
1228
        let scopes = this.getScopesForFile(file);
77✔
1229

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

1233
        const event: ProvideCompletionsEvent = {
77✔
1234
            program: this,
1235
            file: file,
1236
            scopes: scopes,
1237
            position: position,
1238
            completions: []
1239
        };
1240

1241
        this.plugins.emit('beforeProvideCompletions', event);
77✔
1242

1243
        this.plugins.emit('provideCompletions', event);
77✔
1244

1245
        this.plugins.emit('afterProvideCompletions', event);
77✔
1246

1247
        return event.completions;
77✔
1248
    }
1249

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

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

1274
        const event: ProvideDefinitionEvent = {
19✔
1275
            program: this,
1276
            file: file,
1277
            position: position,
1278
            definitions: []
1279
        };
1280

1281
        this.plugins.emit('beforeProvideDefinition', event);
19✔
1282
        this.plugins.emit('provideDefinition', event);
19✔
1283
        this.plugins.emit('afterProvideDefinition', event);
19✔
1284
        return event.definitions;
19✔
1285
    }
1286

1287
    /**
1288
     * Get hover information for a file and position
1289
     */
1290
    public getHover(srcPath: string, position: Position): Hover[] {
1291
        let file = this.getFile(srcPath);
30✔
1292
        let result: Hover[];
1293
        if (file) {
30!
1294
            const event = {
30✔
1295
                program: this,
1296
                file: file,
1297
                position: position,
1298
                scopes: this.getScopesForFile(file),
1299
                hovers: []
1300
            } as ProvideHoverEvent;
1301
            this.plugins.emit('beforeProvideHover', event);
30✔
1302
            this.plugins.emit('provideHover', event);
30✔
1303
            this.plugins.emit('afterProvideHover', event);
30✔
1304
            result = event.hovers;
30✔
1305
        }
1306

1307
        return result ?? [];
30!
1308
    }
1309

1310
    /**
1311
     * Get full list of document symbols for a file
1312
     * @param srcPath path to the file
1313
     */
1314
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1315
        let file = this.getFile(srcPath);
24✔
1316
        if (file) {
24!
1317
            const event: ProvideDocumentSymbolsEvent = {
24✔
1318
                program: this,
1319
                file: file,
1320
                documentSymbols: []
1321
            };
1322
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1323
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1324
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1325
            return event.documentSymbols;
24✔
1326
        } else {
1327
            return undefined;
×
1328
        }
1329
    }
1330

1331
    /**
1332
     * Get the selection ranges for the given positions in a file. Used for expand/shrink selection.
1333
     * @param srcPath path to the file
1334
     * @param positions the positions to get selection ranges for
1335
     */
1336
    public getSelectionRanges(srcPath: string, positions: Position[]): SelectionRange[] {
1337
        const file = this.getFile(srcPath);
15✔
1338
        if (file) {
15✔
1339
            const event: ProvideSelectionRangesEvent = {
14✔
1340
                program: this,
1341
                file: file,
1342
                positions: positions,
1343
                selectionRanges: []
1344
            };
1345
            this.plugins.emit('beforeProvideSelectionRanges', event);
14✔
1346
            this.plugins.emit('provideSelectionRanges', event);
14✔
1347
            this.plugins.emit('afterProvideSelectionRanges', event);
14✔
1348
            return event.selectionRanges;
14✔
1349
        }
1350
        return [];
1✔
1351
    }
1352

1353
    /**
1354
     * Get inlay hints for the given file and range.
1355
     */
1356
    public getInlayHints(srcPath: string, range: Range): InlayHint[] {
1357
        const file = this.getFile(srcPath);
11✔
1358
        if (file) {
11!
1359
            const event: ProvideInlayHintsEvent = {
11✔
1360
                program: this,
1361
                file: file,
1362
                range: range,
1363
                scopes: this.getScopesForFile(file),
1364
                inlayHints: []
1365
            };
1366
            this.plugins.emit('beforeProvideInlayHints', event);
11✔
1367
            this.plugins.emit('provideInlayHints', event);
11✔
1368
            this.plugins.emit('afterProvideInlayHints', event);
11✔
1369
            return event.inlayHints;
11✔
1370
        }
1371
        return [];
×
1372
    }
1373

1374
    /**
1375
     * Compute code actions for the given file and range
1376
     */
1377
    public getCodeActions(srcPath: string, range: Range) {
1378
        const codeActions = [] as CodeAction[];
71✔
1379
        const file = this.getFile(srcPath);
71✔
1380
        if (file) {
71✔
1381
            const diagnostics = this
70✔
1382
                //get all current diagnostics (filtered by diagnostic filters)
1383
                .getDiagnostics()
1384
                //only keep diagnostics related to this file
1385
                .filter(x => x.file === file)
114✔
1386
                //only keep diagnostics that touch this range
1387
                .filter(x => util.rangesIntersectOrTouch(x.range, range));
91✔
1388

1389
            const scopes = this.getScopesForFile(file);
70✔
1390

1391
            this.plugins.emit('onGetCodeActions', {
70✔
1392
                program: this,
1393
                file: file,
1394
                range: range,
1395
                diagnostics: diagnostics,
1396
                scopes: scopes,
1397
                codeActions: codeActions
1398
            });
1399
        }
1400
        return codeActions;
71✔
1401
    }
1402

1403
    /**
1404
     * Compute "source fix all" code actions for the given file.
1405
     * Fires the `onGetSourceFixAllCodeActions` plugin event with all diagnostics for the file (no range filter),
1406
     * then converts each contributed SourceFixAllCodeAction into an LSP CodeAction.
1407
     */
1408
    public getSourceFixAllCodeActions(srcPath: string): CodeAction[] {
1409
        const actions: SourceFixAllCodeAction[] = [];
×
1410
        const file = this.getFile(srcPath);
×
1411
        if (file) {
×
1412
            const diagnostics = this
×
1413
                .getDiagnostics()
1414
                .filter(x => x.file === file);
×
1415
            const scopes = this.getScopesForFile(file);
×
1416
            this.plugins.emit('onGetSourceFixAllCodeActions', {
×
1417
                program: this,
1418
                file: file,
1419
                diagnostics: diagnostics,
1420
                scopes: scopes,
1421
                actions: actions
1422
            } as OnGetSourceFixAllCodeActionsEvent);
1423
        }
1424
        return actions.map(action => codeActionUtil.createCodeAction({
×
1425
            ...action,
1426
            kind: action.kind ?? 'source.fixAll.brighterscript' as any
×
1427
        }));
1428
    }
1429

1430
    /**
1431
     * Get semantic tokens for the specified file
1432
     */
1433
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1434
        const file = this.getFile(srcPath);
16✔
1435
        if (file) {
16!
1436
            const result = [] as SemanticToken[];
16✔
1437
            this.plugins.emit('onGetSemanticTokens', {
16✔
1438
                program: this,
1439
                file: file,
1440
                scopes: this.getScopesForFile(file),
1441
                semanticTokens: result
1442
            });
1443
            return result;
16✔
1444
        }
1445
    }
1446

1447
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1448
        let file: BrsFile = this.getFile(filePath);
188✔
1449
        if (!file || !isBrsFile(file)) {
188✔
1450
            return [];
3✔
1451
        }
1452
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1453
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1454
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1455
    }
1456

1457
    public getReferences(srcPath: string, position: Position): Location[] {
1458
        //find the file
1459
        let file = this.getFile(srcPath);
4✔
1460
        if (!file) {
4!
1461
            return null;
×
1462
        }
1463

1464
        const event: ProvideReferencesEvent = {
4✔
1465
            program: this,
1466
            file: file,
1467
            position: position,
1468
            references: []
1469
        };
1470

1471
        this.plugins.emit('beforeProvideReferences', event);
4✔
1472
        this.plugins.emit('provideReferences', event);
4✔
1473
        this.plugins.emit('afterProvideReferences', event);
4✔
1474

1475
        return event.references;
4✔
1476
    }
1477

1478
    /**
1479
     * Get a list of all script imports, relative to the specified pkgPath
1480
     * @param sourcePkgPath - the pkgPath of the source that wants to resolve script imports.
1481
     */
1482
    public getScriptImportCompletions(sourcePkgPath: string, scriptImport: FileReference) {
1483
        let lowerSourcePkgPath = sourcePkgPath.toLowerCase();
3✔
1484

1485
        let result = [] as CompletionItem[];
3✔
1486
        /**
1487
         * hashtable to prevent duplicate results
1488
         */
1489
        let resultPkgPaths = {} as Record<string, boolean>;
3✔
1490

1491
        //restrict to only .brs files
1492
        for (let key in this.files) {
3✔
1493
            let file = this.files[key];
4✔
1494
            if (
4✔
1495
                //is a BrightScript or BrighterScript file
1496
                (file.extension === '.bs' || file.extension === '.brs') &&
11✔
1497
                //this file is not the current file
1498
                lowerSourcePkgPath !== file.pkgPath.toLowerCase()
1499
            ) {
1500
                //add the relative path
1501
                let relativePath = util.getRelativePath(sourcePkgPath, file.pkgPath).replace(/\\/g, '/');
3✔
1502
                let pkgPathStandardized = file.pkgPath.replace(/\\/g, '/');
3✔
1503
                let filePkgPath = `pkg:/${pkgPathStandardized}`;
3✔
1504
                let lowerFilePkgPath = filePkgPath.toLowerCase();
3✔
1505
                if (!resultPkgPaths[lowerFilePkgPath]) {
3!
1506
                    resultPkgPaths[lowerFilePkgPath] = true;
3✔
1507

1508
                    result.push({
3✔
1509
                        label: relativePath,
1510
                        detail: file.srcPath,
1511
                        kind: CompletionItemKind.File,
1512
                        textEdit: {
1513
                            newText: relativePath,
1514
                            range: scriptImport.filePathRange
1515
                        }
1516
                    });
1517

1518
                    //add the absolute path
1519
                    result.push({
3✔
1520
                        label: filePkgPath,
1521
                        detail: file.srcPath,
1522
                        kind: CompletionItemKind.File,
1523
                        textEdit: {
1524
                            newText: filePkgPath,
1525
                            range: scriptImport.filePathRange
1526
                        }
1527
                    });
1528
                }
1529
            }
1530
        }
1531
        return result;
3✔
1532
    }
1533

1534
    /**
1535
     * Transpile a single file and get the result as a string.
1536
     * This does not write anything to the file system.
1537
     *
1538
     * This should only be called by `LanguageServer`.
1539
     * Internal usage should call `_getTranspiledFileContents` instead.
1540
     * @param filePath can be a srcPath or a destPath
1541
     */
1542
    public async getTranspiledFileContents(filePath: string) {
1543
        const file = this.getFile(filePath);
4✔
1544
        const fileMap: FileObj[] = [{
4✔
1545
            src: file.srcPath,
1546
            dest: file.pkgPath
1547
        }];
1548
        const { entries, astEditor } = this.beforeProgramTranspile(fileMap, this.options.stagingDir);
4✔
1549
        const result = this._getTranspiledFileContents(
4✔
1550
            file
1551
        );
1552
        await this._chainInputSourceMap(result, file);
4✔
1553
        this.afterProgramTranspile(entries, astEditor);
4✔
1554
        return result;
4✔
1555
    }
1556

1557
    /**
1558
     * Internal function used to transpile files.
1559
     * This does not write anything to the file system
1560
     */
1561
    private _getTranspiledFileContents(file: BscFile, outputPath?: string): FileTranspileResult {
1562
        const editor = new AstEditor();
357✔
1563
        this.plugins.emit('beforeFileTranspile', {
357✔
1564
            program: this,
1565
            file: file,
1566
            outputPath: outputPath,
1567
            editor: editor
1568
        });
1569

1570
        //if we have any edits, assume the file needs to be transpiled
1571
        if (editor.hasChanges) {
357✔
1572
            //use the `editor` because it'll track the previous value for us and revert later on
1573
            editor.setProperty(file, 'needsTranspiled', true);
82✔
1574
        }
1575

1576
        //transpile the file
1577
        const result = file.transpile();
357✔
1578

1579
        //generate the typedef if enabled
1580
        let typedef: string;
1581
        if (isBrsFile(file) && this.options.emitDefinitions) {
357✔
1582
            typedef = file.getTypedef();
2✔
1583
        }
1584

1585
        const event: AfterFileTranspileEvent = {
357✔
1586
            program: this,
1587
            file: file,
1588
            outputPath: outputPath,
1589
            editor: editor,
1590
            code: result.code,
1591
            map: result.map,
1592
            typedef: typedef
1593
        };
1594
        this.plugins.emit('afterFileTranspile', event);
357✔
1595

1596
        //undo all `editor` edits that may have been applied to this file.
1597
        editor.undoAll();
357✔
1598

1599
        return {
357✔
1600
            srcPath: file.srcPath,
1601
            pkgPath: file.pkgPath,
1602
            code: event.code,
1603
            map: event.map,
1604
            typedef: event.typedef
1605
        };
1606
    }
1607

1608
    /**
1609
     * If the file has an incoming sourcemap (from a prebuild step), chain it into the
1610
     * generated sourcemap so the output map traces all the way back to the original source.
1611
     * This is async because SourceMapConsumer requires async initialisation in source-map v0.7.
1612
     */
1613
    private async _chainInputSourceMap(result: FileTranspileResult, file: BscFile): Promise<void> {
1614
        if (result.map) {
61✔
1615
            const inputMap = await util.resolveInputSourceMap(file.fileContents ?? '', file.srcPath);
29!
1616
            if (inputMap) {
29✔
1617
                await util.applySourceMap(result.map, inputMap, file.srcPath);
8✔
1618
            }
1619
        }
1620
    }
1621

1622
    private beforeProgramTranspile(fileEntries: FileObj[], stagingDir: string) {
1623
        // map fileEntries using their path as key, to avoid excessive "find()" operations
1624
        const mappedFileEntries = fileEntries.reduce<Record<string, FileObj>>((collection, entry) => {
61✔
1625
            collection[s`${entry.src}`] = entry;
44✔
1626
            return collection;
44✔
1627
        }, {});
1628

1629
        const getOutputPath = (file: BscFile) => {
61✔
1630
            let filePathObj = mappedFileEntries[s`${file.srcPath}`];
125✔
1631
            if (!filePathObj) {
125✔
1632
                //this file has been added in-memory, from a plugin, for example
1633
                filePathObj = {
47✔
1634
                    //add an interpolated src path (since it doesn't actually exist in memory)
1635
                    src: `bsc:/${file.pkgPath}`,
1636
                    dest: file.pkgPath
1637
                };
1638
            }
1639
            //replace the file extension
1640
            let outputPath = filePathObj.dest.replace(/\.bs$/gi, '.brs');
125✔
1641
            //prepend the staging folder path
1642
            outputPath = s`${stagingDir}/${outputPath}`;
125✔
1643
            return outputPath;
125✔
1644
        };
1645

1646
        const entries = Object.values(this.files)
61✔
1647
            //only include the files from fileEntries
1648
            .filter(file => !!mappedFileEntries[file.srcPath])
63✔
1649
            .map(file => {
1650
                return {
42✔
1651
                    file: file,
1652
                    outputPath: getOutputPath(file)
1653
                };
1654
            })
1655
            //sort the entries to make transpiling more deterministic
1656
            .sort((a, b) => {
1657
                return a.file.srcPath < b.file.srcPath ? -1 : 1;
6✔
1658
            });
1659

1660
        const astEditor = new AstEditor();
61✔
1661

1662
        this.plugins.emit('beforeProgramTranspile', this, entries, astEditor);
61✔
1663
        return {
61✔
1664
            entries: entries,
1665
            getOutputPath: getOutputPath,
1666
            astEditor: astEditor
1667
        };
1668
    }
1669

1670
    public async transpile(fileEntries: FileObj[], stagingDir: string) {
1671
        const { entries, getOutputPath, astEditor } = this.beforeProgramTranspile(fileEntries, stagingDir);
56✔
1672

1673
        const processedFiles = new Set<string>();
56✔
1674

1675
        const transpileFile = async (srcPath: string, outputPath?: string) => {
56✔
1676
            //find the file in the program
1677
            const file = this.getFile(srcPath);
59✔
1678
            //mark this file as processed so we don't process it more than once
1679
            processedFiles.add(outputPath?.toLowerCase());
59!
1680

1681
            if (!this.options.pruneEmptyCodeFiles || !file.canBePruned) {
59✔
1682
                //skip transpiling typedef files
1683
                if (isBrsFile(file) && file.isTypedef) {
58✔
1684
                    return;
1✔
1685
                }
1686

1687
                const fileTranspileResult = this._getTranspiledFileContents(file, outputPath);
57✔
1688
                await this._chainInputSourceMap(fileTranspileResult, file);
57✔
1689

1690
                //make sure the full dir path exists
1691
                await fsExtra.ensureDir(path.dirname(outputPath));
57✔
1692

1693
                if (await fsExtra.pathExists(outputPath)) {
57!
1694
                    throw new Error(`Error while transpiling "${file.srcPath}". A file already exists at "${outputPath}" and will not be overwritten.`);
×
1695
                }
1696
                const writeMapPromise = fileTranspileResult.map ? fsExtra.writeFile(`${outputPath}.map`, this.serializeSourceMap(fileTranspileResult.map, outputPath)) : null;
57✔
1697
                await Promise.all([
57✔
1698
                    fsExtra.writeFile(outputPath, fileTranspileResult.code),
1699
                    writeMapPromise
1700
                ]);
1701

1702
                if (fileTranspileResult.typedef) {
57✔
1703
                    const typedefPath = outputPath.replace(/\.brs$/i, '.d.bs');
2✔
1704
                    await fsExtra.writeFile(typedefPath, fileTranspileResult.typedef);
2✔
1705
                }
1706
            }
1707
        };
1708

1709
        let promises = entries.map(async (entry) => {
56✔
1710
            return transpileFile(entry?.file?.srcPath, entry.outputPath);
36!
1711
        });
1712

1713
        //if there's no bslib file already loaded into the program, copy it to the staging directory
1714
        if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) {
56✔
1715
            promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir));
55✔
1716
        }
1717
        await Promise.all(promises);
56✔
1718

1719
        //transpile any new files that plugins added since the start of this transpile process
1720
        do {
56✔
1721
            promises = [];
76✔
1722
            for (const key in this.files) {
76✔
1723
                const file = this.files[key];
83✔
1724
                //this is a new file
1725
                const outputPath = getOutputPath(file);
83✔
1726
                if (!processedFiles.has(outputPath?.toLowerCase())) {
83!
1727
                    promises.push(
23✔
1728
                        transpileFile(file?.srcPath, outputPath)
69!
1729
                    );
1730
                }
1731
            }
1732
            if (promises.length > 0) {
76✔
1733
                this.logger.info(`Transpiling ${promises.length} new files`);
20✔
1734
                await Promise.all(promises);
20✔
1735
            }
1736
        }
1737
        while (promises.length > 0);
1738
        this.afterProgramTranspile(entries, astEditor);
56✔
1739
    }
1740

1741
    private afterProgramTranspile(entries: TranspileObj[], astEditor: AstEditor) {
1742
        this.plugins.emit('afterProgramTranspile', this, entries, astEditor);
60✔
1743
        astEditor.undoAll();
60✔
1744
    }
1745

1746
    /**
1747
     * Serialize a source map to a JSON string, handling relativeSourceMaps and sourceRoot.
1748
     *
1749
     * relativeSourceMaps:false — legacy: sources[] has absolute paths (rootDir swapped for sourceRoot
1750
     * if set); map's sourceRoot field is never written.
1751
     *
1752
     * relativeSourceMaps:true, no sourceRoot — sources[] is relative to the map file directory.
1753
     *
1754
     * relativeSourceMaps:true, sourceRoot set — map's sourceRoot field is written; sources[] is
1755
     * relative to sourceRoot so that path.resolve(sourceRoot, sources[0]) gives the source file.
1756
     */
1757
    private serializeSourceMap(sourceMap: SourceMapGenerator, outputPath: string): string {
1758
        const { relativeSourceMaps, sourceRoot } = this.options;
29✔
1759

1760
        if (!relativeSourceMaps) {
29✔
1761
            return sourceMap.toString();
21✔
1762
        }
1763

1764
        const mapJson = sourceMap.toJSON();
8✔
1765

1766
        if (sourceRoot) {
8✔
1767
            mapJson.sourceRoot = sourceRoot;
4✔
1768
            mapJson.sources = mapJson.sources.map((source: string) => {
4✔
1769
                const absolute = path.isAbsolute(source) ? source : path.resolve(path.dirname(outputPath), source);
4!
1770
                return path.relative(sourceRoot, absolute).replace(/\\/g, '/');
4✔
1771
            });
1772
        } else {
1773
            const mapDir = path.dirname(outputPath);
4✔
1774
            mapJson.sources = mapJson.sources.map((source: string) => {
4✔
1775
                return path.isAbsolute(source)
4✔
1776
                    ? path.relative(mapDir, source).replace(/\\/g, '/')
4!
1777
                    : source;
1778
            });
1779
        }
1780

1781
        return JSON.stringify(mapJson);
8✔
1782
    }
1783

1784
    /**
1785
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1786
     */
1787
    public findFilesForFunction(functionName: string) {
1788
        const files = [] as BscFile[];
47✔
1789
        const lowerFunctionName = functionName.toLowerCase();
47✔
1790
        //find every file with this function defined
1791
        for (const file of Object.values(this.files)) {
47✔
1792
            if (isBrsFile(file)) {
137✔
1793
                //TODO handle namespace-relative function calls
1794
                //if the file has a function with this name
1795
                if (file.parser.references.functionStatementLookup.get(lowerFunctionName) !== undefined) {
102✔
1796
                    files.push(file);
24✔
1797
                }
1798
            }
1799
        }
1800
        return files;
47✔
1801
    }
1802

1803
    /**
1804
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1805
     */
1806
    public findFilesForClass(className: string) {
1807
        const files = [] as BscFile[];
47✔
1808
        const lowerClassName = className.toLowerCase();
47✔
1809
        //find every file with this class defined
1810
        for (const file of Object.values(this.files)) {
47✔
1811
            if (isBrsFile(file)) {
137✔
1812
                //TODO handle namespace-relative classes
1813
                //if the file has a function with this name
1814
                if (file.parser.references.classStatementLookup.get(lowerClassName) !== undefined) {
102✔
1815
                    files.push(file);
3✔
1816
                }
1817
            }
1818
        }
1819
        return files;
47✔
1820
    }
1821

1822
    public findFilesForNamespace(name: string) {
1823
        const files = [] as BscFile[];
47✔
1824
        const lowerName = name.toLowerCase();
47✔
1825
        //find every file with this class defined
1826
        for (const file of Object.values(this.files)) {
47✔
1827
            if (isBrsFile(file)) {
137✔
1828
                if (file.parser.references.namespaceStatements.find((x) => {
102✔
1829
                    const namespaceName = x.name.toLowerCase();
14✔
1830
                    return (
14✔
1831
                        //the namespace name matches exactly
1832
                        namespaceName === lowerName ||
18✔
1833
                        //the full namespace starts with the name (honoring the part boundary)
1834
                        namespaceName.startsWith(lowerName + '.')
1835
                    );
1836
                })) {
1837
                    files.push(file);
12✔
1838
                }
1839
            }
1840
        }
1841
        return files;
47✔
1842
    }
1843

1844
    public findFilesForEnum(name: string) {
1845
        const files = [] as BscFile[];
48✔
1846
        const lowerName = name.toLowerCase();
48✔
1847
        //find every file with this class defined
1848
        for (const file of Object.values(this.files)) {
48✔
1849
            if (isBrsFile(file)) {
138✔
1850
                if (file.parser.references.enumStatementLookup.get(lowerName)) {
103✔
1851
                    files.push(file);
1✔
1852
                }
1853
            }
1854
        }
1855
        return files;
48✔
1856
    }
1857

1858
    private _manifest: Map<string, string>;
1859
    private _manifestEntries: ManifestEntry[];
1860

1861
    /**
1862
     * The absolute source path to the manifest file. Set when loadManifest is called.
1863
     */
1864
    public manifestPath: string;
1865

1866
    /**
1867
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1868
     * @param parsedManifest The manifest map to read from and modify
1869
     */
1870
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1871
        // Lift the bs_consts defined in the manifest
1872
        let bsConsts = getBsConst(parsedManifest, false);
53✔
1873

1874
        // Override or delete any bs_consts defined in the bs config
1875
        for (const key in this.options?.manifest?.bs_const) {
53!
1876
            const value = this.options.manifest.bs_const[key];
3✔
1877
            if (value === null) {
3✔
1878
                bsConsts.delete(key);
1✔
1879
            } else {
1880
                bsConsts.set(key, value);
2✔
1881
            }
1882
        }
1883

1884
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1885
        let constString = '';
53✔
1886
        for (const [key, value] of bsConsts) {
53✔
1887
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1888
        }
1889

1890
        // Set the updated bs_const value
1891
        parsedManifest.set('bs_const', constString);
53✔
1892
    }
1893

1894
    /**
1895
     * Try to find and load the manifest into memory
1896
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1897
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1898
     */
1899
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,347✔
1900
        //if we already have a manifest instance, and should not replace...then don't replace
1901
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,362!
1902
            return;
×
1903
        }
1904
        let manifestPath = manifestFileObj
1,362✔
1905
            ? manifestFileObj.src
1,362✔
1906
            : path.join(this.options.rootDir, 'manifest');
1907

1908
        //store the resolved manifest path so it can be used externally for change detection
1909
        this.manifestPath = util.standardizePath(manifestPath);
1,362✔
1910

1911
        try {
1,362✔
1912
            // we only load this manifest once, so do it sync to improve speed downstream
1913
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,362✔
1914
            const parsedManifest = parseManifest(contents);
53✔
1915
            this.buildBsConstsIntoParsedManifest(parsedManifest);
53✔
1916
            this._manifest = parsedManifest;
53✔
1917
            this._manifestEntries = parseManifestEntries(contents);
53✔
1918
            this._manifestPath = manifestPath;
53✔
1919
        } catch (e) {
1920
            this._manifest = new Map();
1,309✔
1921
            this._manifestEntries = [];
1,309✔
1922
            this._manifestPath = undefined;
1,309✔
1923
        }
1924
    }
1925

1926
    /**
1927
     * Get a map of the manifest information
1928
     */
1929
    public getManifest() {
1930
        if (!this._manifest) {
1,597✔
1931
            this.loadManifest();
1,199✔
1932
        }
1933
        return this._manifest;
1,597✔
1934
    }
1935

1936
    /**
1937
     * Get the manifest as a list of `{ key, value, range }` entries with line/column ranges
1938
     * suitable for attaching diagnostics to specific manifest lines.
1939
     *
1940
     * NOTE: protected for now. The shape of this data is likely to evolve as we build out
1941
     * more manifest-aware features (validation rules, autocomplete, etc.). External plugins
1942
     * shouldn't depend on this until we commit to a stable API.
1943
     */
1944
    protected getManifestEntries(): ManifestEntry[] {
1945
        if (!this._manifestEntries) {
1,119✔
1946
            this.loadManifest();
147✔
1947
        }
1948
        return this._manifestEntries;
1,119✔
1949
    }
1950

1951
    private _manifestPath: string | undefined;
1952

1953
    /**
1954
     * Returns the absolute path of the loaded manifest file, or undefined if no manifest was found.
1955
     *
1956
     * NOTE: protected for now. Once brighterscript treats the manifest as a proper file
1957
     * (with editor / BscFile integration) callers should consume that instead of poking at
1958
     * the Program. External plugins shouldn't depend on this in the meantime.
1959
     */
1960
    protected getManifestPath(): string | undefined {
1961
        if (!this._manifest) {
1,117!
1962
            this.loadManifest();
×
1963
        }
1964
        return this._manifestPath;
1,117✔
1965
    }
1966

1967
    private _minFirmwareVersion: string | undefined;
1968

1969
    /**
1970
     * The minimum Roku firmware version brighterscript should assume the user is targeting.
1971
     * If `options.minFirmwareVersion` is set AND parseable as semver, the canonical coerced
1972
     * form ("15.0" → "15.0.0") wins; otherwise (unset or unparseable) falls back to
1973
     * {@link DEFAULT_MIN_FIRMWARE_VERSION}. The return is always a valid semver string so
1974
     * downstream callers can pass it directly to `semver.gte`/`semver.lt` without re-coercing.
1975
     * Cached after first call.
1976
     */
1977
    public getMinFirmwareVersion(): string {
1978
        if (this._minFirmwareVersion === undefined) {
70✔
1979
            const userValue = this.options.minFirmwareVersion;
48✔
1980
            const coerced = userValue ? semver.coerce(userValue) : undefined;
48✔
1981
            this._minFirmwareVersion = coerced ? coerced.version : DEFAULT_MIN_FIRMWARE_VERSION;
48✔
1982
        }
1983
        return this._minFirmwareVersion;
70✔
1984
    }
1985

1986
    private _rsgVersion: string | undefined;
1987

1988
    /**
1989
     * Returns the effective `rsg_version` for this program in canonical semver form (e.g. "1.2"
1990
     * → "1.2.0"). If the manifest declares a value explicitly and it's parseable, the canonical
1991
     * form wins; otherwise (manifest silent OR value is malformed) we fall back to the highest
1992
     * known rsg_version whose `becameDefaultAt` is `<=` the effective minimum firmware version,
1993
     * driven by {@link RSG_VERSIONS}. Cached after first call.
1994
     *
1995
     * Manifest validation (format errors, etc.) happens in `ProgramValidator` against the raw
1996
     * entry — that path doesn't go through this method.
1997
     */
1998
    public getRsgVersion(): string {
1999
        if (this._rsgVersion !== undefined) {
16!
2000
            return this._rsgVersion;
×
2001
        }
2002
        const explicit = this.getManifest().get('rsg_version');
16✔
2003
        const explicitCoerced = explicit !== undefined ? semver.coerce(explicit.trim()) : undefined;
16✔
2004
        if (explicitCoerced) {
16✔
2005
            this._rsgVersion = explicitCoerced.version;
5✔
2006
            return this._rsgVersion;
5✔
2007
        }
2008
        //walk known rsg_versions in descending order (newest first) and return the first whose
2009
        //becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0'
2010
        //(currently `1.1`), this loop always finds a match.
2011
        const minFirmwareVersion = this.getMinFirmwareVersion();
11✔
2012
        const candidates = Object.entries(RSG_VERSIONS)
11✔
2013
            .filter(([, info]) => info.becameDefaultAt !== undefined)
44✔
2014
            .sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!));
33✔
2015
        for (const [version, info] of candidates) {
11✔
2016
            if (semver.gte(minFirmwareVersion, info.becameDefaultAt!)) {
25✔
2017
                this._rsgVersion = semver.coerce(version)!.version;
11✔
2018
                return this._rsgVersion;
11✔
2019
            }
2020
        }
2021
        //unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0'
2022
        this._rsgVersion = DEFAULT_MIN_FIRMWARE_VERSION;
×
2023
        return this._rsgVersion;
×
2024
    }
2025

2026
    public dispose() {
2027
        this.plugins.emit('beforeProgramDispose', { program: this });
1,557✔
2028

2029
        for (let filePath in this.files) {
1,557✔
2030
            this.files[filePath].dispose();
1,532✔
2031
        }
2032
        for (let name in this.scopes) {
1,557✔
2033
            this.scopes[name].dispose();
3,059✔
2034
        }
2035
        this.globalScope.dispose();
1,557✔
2036
        this.dependencyGraph.dispose();
1,557✔
2037
    }
2038
}
2039

2040
export interface FileTranspileResult {
2041
    srcPath: string;
2042
    pkgPath: string;
2043
    code: string;
2044
    map: SourceMapGenerator;
2045
    typedef: string;
2046
}
2047

2048
/**
2049
 * Where a resolved SceneGraph field was declared, relative to the node it was resolved for:
2050
 * `own` = declared on the node itself, `inherited` = declared on an ancestor in the `extends` chain
2051
 */
2052
export type SceneGraphFieldOrigin = 'own' | 'inherited';
2053

2054
/**
2055
 * A field available on a SceneGraph node, resolved across the node's full `extends` chain
2056
 */
2057
export interface ResolvedSceneGraphField {
2058
    name: string;
2059
    type?: string;
2060
    default?: string;
2061
    description?: string;
2062
    accessPermission?: string;
2063
    origin: SceneGraphFieldOrigin;
2064
}
2065

2066
/**
2067
 * The built-in Roku node data and/or project component file backing a SceneGraph node name
2068
 */
2069
export interface SceneGraphNodeLookup {
2070
    builtInNode?: SGNodeData;
2071
    componentFile?: XmlFile;
2072
}
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