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

rokucommunity / brighterscript / #14099

03 Apr 2025 11:54AM UTC coverage: 87.114% (+0.006%) from 87.108%
#14099

push

web-flow
Merge 3bb79f3b1 into df7c6dea7

13291 of 16126 branches covered (82.42%)

Branch coverage included in aggregate %.

35 of 36 new or added lines in 6 files covered. (97.22%)

156 existing lines in 4 files now uncovered.

14379 of 15637 relevant lines covered (91.95%)

19610.15 hits per line

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

93.02
/src/Program.ts
1
import * as assert from 'assert';
1✔
2
import * as fsExtra from 'fs-extra';
1✔
3
import * as path from 'path';
1✔
4
import type { CodeAction, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken } from 'vscode-languageserver';
5
import { CancellationTokenSource } from 'vscode-languageserver';
1✔
6
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
7
import { Scope } from './Scope';
1✔
8
import { DiagnosticMessages } from './DiagnosticMessages';
1✔
9
import type { FileObj, SemanticToken, FileLink, ProvideHoverEvent, ProvideCompletionsEvent, Hover, ProvideDefinitionEvent, ProvideReferencesEvent, ProvideDocumentSymbolsEvent, ProvideWorkspaceSymbolsEvent, BeforeFileAddEvent, BeforeFileRemoveEvent, PrepareFileEvent, PrepareProgramEvent, ProvideFileEvent, SerializedFile, TranspileObj, SerializeFileEvent, ScopeValidationOptions, ExtraSymbolData } from './interfaces';
10
import { standardizePath as s, util } from './util';
1✔
11
import { XmlScope } from './XmlScope';
1✔
12
import { DependencyGraph } from './DependencyGraph';
1✔
13
import type { Logger } from './logging';
14
import { LogLevel, createLogger } from './logging';
1✔
15
import chalk from 'chalk';
1✔
16
import { globalCallables, globalFile } from './globalCallables';
1✔
17
import { parseManifest, getBsConst } from './preprocessor/Manifest';
1✔
18
import { URI } from 'vscode-uri';
1✔
19
import PluginInterface from './PluginInterface';
1✔
20
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement, isReferenceType } from './astUtils/reflection';
1✔
21
import type { FunctionStatement, MethodStatement, NamespaceStatement } from './parser/Statement';
22
import { BscPlugin } from './bscPlugin/BscPlugin';
1✔
23
import { Editor } from './astUtils/Editor';
1✔
24
import { IntegerType } from './types/IntegerType';
1✔
25
import { StringType } from './types/StringType';
1✔
26
import { SymbolTypeFlag } from './SymbolTypeFlag';
1✔
27
import { BooleanType } from './types/BooleanType';
1✔
28
import { DoubleType } from './types/DoubleType';
1✔
29
import { DynamicType } from './types/DynamicType';
1✔
30
import { FloatType } from './types/FloatType';
1✔
31
import { LongIntegerType } from './types/LongIntegerType';
1✔
32
import { ObjectType } from './types/ObjectType';
1✔
33
import { VoidType } from './types/VoidType';
1✔
34
import { FunctionType } from './types/FunctionType';
1✔
35
import { FileFactory } from './files/Factory';
1✔
36
import { ActionPipeline } from './ActionPipeline';
1✔
37
import type { FileData } from './files/LazyFileData';
38
import { LazyFileData } from './files/LazyFileData';
1✔
39
import { rokuDeploy } from 'roku-deploy';
1✔
40
import type { SGNodeData, BRSComponentData, BRSEventData, BRSInterfaceData } from './roku-types';
41
import { nodes, components, interfaces, events } from './roku-types';
1✔
42
import { ComponentType } from './types/ComponentType';
1✔
43
import { InterfaceType } from './types/InterfaceType';
1✔
44
import { BuiltInInterfaceAdder } from './types/BuiltInInterfaceAdder';
1✔
45
import type { UnresolvedSymbol } from './AstValidationSegmenter';
46
import { WalkMode, createVisitor } from './astUtils/visitors';
1✔
47
import type { BscFile } from './files/BscFile';
48
import { firstBy } from 'thenby';
1✔
49
import { CrossScopeValidator } from './CrossScopeValidator';
1✔
50
import { DiagnosticManager } from './DiagnosticManager';
1✔
51
import { ProgramValidatorDiagnosticsTag } from './bscPlugin/validation/ProgramValidator';
1✔
52
import type { ProvidedSymbolInfo, BrsFile } from './files/BrsFile';
53
import type { XmlFile } from './files/XmlFile';
54
import { SymbolTable } from './SymbolTable';
1✔
55
import { ReferenceType, TypesCreated } from './types';
1✔
56
import type { Statement } from './parser/AstNode';
57
import { CallExpressionInfo } from './bscPlugin/CallExpressionInfo';
1✔
58
import { SignatureHelpUtil } from './bscPlugin/SignatureHelpUtil';
1✔
59
import { Sequencer } from './common/Sequencer';
1✔
60
import { Deferred } from './deferred';
1✔
61

62
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
63
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
64

65
export interface SignatureInfoObj {
66
    index: number;
67
    key: string;
68
    signature: SignatureInformation;
69
}
70

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

86
        // initialize the diagnostics Manager
87
        this.diagnostics.logger = this.logger;
1,945✔
88
        this.diagnostics.options = this.options;
1,945✔
89
        this.diagnostics.program = this;
1,945✔
90

91
        //inject the bsc plugin as the first plugin in the stack.
92
        this.plugins.addFirst(new BscPlugin());
1,945✔
93

94
        //normalize the root dir path
95
        this.options.rootDir = util.getRootDir(this.options);
1,945✔
96

97
        this.createGlobalScope();
1,945✔
98

99
        this.fileFactory = new FileFactory(this);
1,945✔
100
    }
101

102
    public options: FinalizedBsConfig;
103
    public logger: Logger;
104

105
    /**
106
     * An editor that plugins can use to modify program-level things during the build flow. Don't use this to edit files (they have their own `.editor`)
107
     */
108
    public editor = new Editor();
1,945✔
109

110
    /**
111
     * A factory that creates `File` instances
112
     */
113
    private fileFactory: FileFactory;
114

115
    private createGlobalScope() {
116
        //create the 'global' scope
117
        this.globalScope = new Scope('global', this, 'scope:global');
1,945✔
118
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,945✔
119
        this.scopes.global = this.globalScope;
1,945✔
120

121
        this.populateGlobalSymbolTable();
1,945✔
122
        this.globalScope.symbolTable.addSibling(this.componentsTable);
1,945✔
123

124
        //hardcode the files list for global scope to only contain the global file
125
        this.globalScope.getAllFiles = () => [globalFile];
20,091✔
126
        globalFile.isValidated = true;
1,945✔
127
        this.globalScope.validate();
1,945✔
128

129
        //TODO we might need to fix this because the isValidated clears stuff now
130
        (this.globalScope as any).isValidated = true;
1,945✔
131
    }
132

133

134
    private recursivelyAddNodeToSymbolTable(nodeData: SGNodeData) {
135
        if (!nodeData) {
359,825!
UNCOV
136
            return;
×
137
        }
138
        let nodeType: ComponentType;
139
        const nodeName = util.getSgNodeTypeName(nodeData.name);
359,825✔
140
        if (!this.globalScope.symbolTable.hasSymbol(nodeName, SymbolTypeFlag.typetime)) {
359,825✔
141
            let parentNode: ComponentType;
142
            if (nodeData.extends) {
186,720✔
143
                const parentNodeData = nodes[nodeData.extends.name.toLowerCase()];
173,105✔
144
                try {
173,105✔
145
                    parentNode = this.recursivelyAddNodeToSymbolTable(parentNodeData);
173,105✔
146
                } catch (error) {
UNCOV
147
                    this.logger.error(error, nodeData);
×
148
                }
149
            }
150
            nodeType = new ComponentType(nodeData.name, parentNode);
186,720✔
151
            nodeType.addBuiltInInterfaces();
186,720✔
152
            nodeType.isBuiltIn = true;
186,720✔
153
            if (nodeData.name === 'Node') {
186,720✔
154
                // Add `roSGNode` as shorthand for `roSGNodeNode`
155
                this.globalScope.symbolTable.addSymbol('roSGNode', { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
1,945✔
156
            }
157
            this.globalScope.symbolTable.addSymbol(nodeName, { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
186,720✔
158
        } else {
159
            nodeType = this.globalScope.symbolTable.getSymbolType(nodeName, { flags: SymbolTypeFlag.typetime }) as ComponentType;
173,105✔
160
        }
161

162
        return nodeType;
359,825✔
163
    }
164
    /**
165
     * Do all setup required for the global symbol table.
166
     */
167
    private populateGlobalSymbolTable() {
168
        //Setup primitive types in global symbolTable
169

170
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
1,945✔
171

172
        this.globalScope.symbolTable.addSymbol('boolean', builtInSymbolData, BooleanType.instance, SymbolTypeFlag.typetime);
1,945✔
173
        this.globalScope.symbolTable.addSymbol('double', builtInSymbolData, DoubleType.instance, SymbolTypeFlag.typetime);
1,945✔
174
        this.globalScope.symbolTable.addSymbol('dynamic', builtInSymbolData, DynamicType.instance, SymbolTypeFlag.typetime);
1,945✔
175
        this.globalScope.symbolTable.addSymbol('float', builtInSymbolData, FloatType.instance, SymbolTypeFlag.typetime);
1,945✔
176
        this.globalScope.symbolTable.addSymbol('function', builtInSymbolData, FunctionType.instance, SymbolTypeFlag.typetime);
1,945✔
177
        this.globalScope.symbolTable.addSymbol('integer', builtInSymbolData, IntegerType.instance, SymbolTypeFlag.typetime);
1,945✔
178
        this.globalScope.symbolTable.addSymbol('longinteger', builtInSymbolData, LongIntegerType.instance, SymbolTypeFlag.typetime);
1,945✔
179
        this.globalScope.symbolTable.addSymbol('object', builtInSymbolData, ObjectType.instance, SymbolTypeFlag.typetime);
1,945✔
180
        this.globalScope.symbolTable.addSymbol('string', builtInSymbolData, StringType.instance, SymbolTypeFlag.typetime);
1,945✔
181
        this.globalScope.symbolTable.addSymbol('void', builtInSymbolData, VoidType.instance, SymbolTypeFlag.typetime);
1,945✔
182

183
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
568,178✔
184

185
        for (const callable of globalCallables) {
1,945✔
186
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
151,710✔
187
        }
188

189
        for (const ifaceData of Object.values(interfaces) as BRSInterfaceData[]) {
1,945✔
190
            const nodeType = new InterfaceType(ifaceData.name);
171,160✔
191
            nodeType.addBuiltInInterfaces();
171,160✔
192
            nodeType.isBuiltIn = true;
171,160✔
193
            this.globalScope.symbolTable.addSymbol(ifaceData.name, { ...builtInSymbolData, description: ifaceData.description }, nodeType, SymbolTypeFlag.typetime);
171,160✔
194
        }
195

196
        for (const componentData of Object.values(components) as BRSComponentData[]) {
1,945✔
197
            const nodeType = new InterfaceType(componentData.name);
126,425✔
198
            nodeType.addBuiltInInterfaces();
126,425✔
199
            nodeType.isBuiltIn = true;
126,425✔
200
            if (componentData.name !== 'roSGNode') {
126,425✔
201
                // we will add `roSGNode` as shorthand for `roSGNodeNode`, since all roSgNode components are SceneGraph nodes
202
                this.globalScope.symbolTable.addSymbol(componentData.name, { ...builtInSymbolData, description: componentData.description }, nodeType, SymbolTypeFlag.typetime);
124,480✔
203
            }
204
        }
205

206
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,945✔
207
            this.recursivelyAddNodeToSymbolTable(nodeData);
186,720✔
208
        }
209

210
        for (const eventData of Object.values(events) as BRSEventData[]) {
1,945✔
211
            const nodeType = new InterfaceType(eventData.name);
35,010✔
212
            nodeType.addBuiltInInterfaces();
35,010✔
213
            nodeType.isBuiltIn = true;
35,010✔
214
            this.globalScope.symbolTable.addSymbol(eventData.name, { ...builtInSymbolData, description: eventData.description }, nodeType, SymbolTypeFlag.typetime);
35,010✔
215
        }
216

217
    }
218

219
    /**
220
     * A graph of all files and their dependencies.
221
     * For example:
222
     *      File.xml -> [lib1.brs, lib2.brs]
223
     *      lib2.brs -> [lib3.brs] //via an import statement
224
     */
225
    private dependencyGraph = new DependencyGraph();
1,945✔
226

227
    public diagnostics: DiagnosticManager;
228

229
    /**
230
     * A scope that contains all built-in global functions.
231
     * All scopes should directly or indirectly inherit from this scope
232
     */
233
    public globalScope: Scope = undefined as any;
1,945✔
234

235
    /**
236
     * Plugins which can provide extra diagnostics or transform AST
237
     */
238
    public plugins: PluginInterface;
239

240
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
1,945✔
241

242
    private currentScopeValidationOptions: ScopeValidationOptions;
243

244
    /**
245
     *  Map of typetime symbols which depend upon the key symbol
246
     */
247
    private symbolDependencies = new Map<string, Set<string>>();
1,945✔
248

249

250
    /**
251
     * Symbol Table for storing custom component types
252
     * This is a sibling to the global table (as Components can be used/referenced anywhere)
253
     * Keeping custom components out of the global table and in a specific symbol table
254
     * compartmentalizes their use
255
     */
256
    private componentsTable = new SymbolTable('Custom Components');
1,945✔
257

258
    public addFileSymbolInfo(file: BrsFile) {
259
        this.fileSymbolInformation.set(file.pkgPath, {
1,852✔
260
            provides: file.providedSymbols,
261
            requires: file.requiredSymbols
262
        });
263
    }
264

265
    public getFileSymbolInfo(file: BrsFile) {
266
        return this.fileSymbolInformation.get(file.pkgPath);
1,882✔
267
    }
268

269
    /**
270
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
271
     */
272
    public get bslibPkgPath() {
273
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
274
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
2,481✔
275
            return bslibAliasedRokuModulesPkgPath;
11✔
276

277
            //if there's a non-aliased version of bslib from roku_modules, use that
278
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
2,470✔
279
            return bslibNonAliasedRokuModulesPkgPath;
24✔
280

281
            //default to the embedded version
282
        } else {
283
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
2,446✔
284
        }
285
    }
286

287
    public get bslibPrefix() {
288
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
1,807✔
289
            return 'rokucommunity_bslib';
18✔
290
        } else {
291
            return 'bslib';
1,789✔
292
        }
293
    }
294

295

296
    /**
297
     * A map of every file loaded into this program, indexed by its original file location
298
     */
299
    public files = {} as Record<string, BscFile>;
1,945✔
300
    /**
301
     * A map of every file loaded into this program, indexed by its destPath
302
     */
303
    private destMap = new Map<string, BscFile>();
1,945✔
304
    /**
305
     * Plugins can contribute multiple virtual files for a single physical file.
306
     * This collection links the virtual files back to the physical file that produced them.
307
     * The key is the standardized and lower-cased srcPath
308
     */
309
    private fileClusters = new Map<string, BscFile[]>();
1,945✔
310

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

313
    protected addScope(scope: Scope) {
314
        this.scopes[scope.name] = scope;
2,117✔
315
        delete this.sortedScopeNames;
2,117✔
316
    }
317

318
    protected removeScope(scope: Scope) {
319
        if (this.scopes[scope.name]) {
16!
320
            delete this.scopes[scope.name];
16✔
321
            delete this.sortedScopeNames;
16✔
322
        }
323
    }
324

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

333
    /**
334
     * Get the component with the specified name
335
     */
336
    public getComponent(componentName: string) {
337
        if (componentName) {
3,037✔
338
            //return the first compoment in the list with this name
339
            //(components are ordered in this list by destPath to ensure consistency)
340
            return this.components[componentName.toLowerCase()]?.[0];
3,003✔
341
        } else {
342
            return undefined;
34✔
343
        }
344
    }
345

346
    /**
347
     * Get the sorted names of custom components
348
     */
349
    public getSortedComponentNames() {
350
        const componentNames = Object.keys(this.components);
1,505✔
351
        componentNames.sort((a, b) => {
1,505✔
352
            if (a < b) {
823✔
353
                return -1;
295✔
354
            } else if (b < a) {
528!
355
                return 1;
528✔
356
            }
UNCOV
357
            return 0;
×
358
        });
359
        return componentNames;
1,505✔
360
    }
361

362
    /**
363
     * Keeps a set of all the components that need to have their types updated during the current validation cycle
364
     * Map <componentKey, componentName>
365
     */
366
    private componentSymbolsToUpdate = new Map<string, string>();
1,945✔
367

368
    /**
369
     * Register (or replace) the reference to a component in the component map
370
     */
371
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
372
        const key = this.getComponentKey(xmlFile);
447✔
373
        if (!this.components[key]) {
447✔
374
            this.components[key] = [];
430✔
375
        }
376
        this.components[key].push({
447✔
377
            file: xmlFile,
378
            scope: scope
379
        });
380
        this.components[key].sort((a, b) => {
447✔
381
            const pathA = a.file.destPath.toLowerCase();
5✔
382
            const pathB = b.file.destPath.toLowerCase();
5✔
383
            if (pathA < pathB) {
5✔
384
                return -1;
1✔
385
            } else if (pathA > pathB) {
4!
386
                return 1;
4✔
387
            }
UNCOV
388
            return 0;
×
389
        });
390
        this.syncComponentDependencyGraph(this.components[key]);
447✔
391
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
447✔
392
    }
393

394
    /**
395
     * Remove the specified component from the components map
396
     */
397
    private unregisterComponent(xmlFile: XmlFile) {
398
        const key = this.getComponentKey(xmlFile);
16✔
399
        const arr = this.components[key] || [];
16!
400
        for (let i = 0; i < arr.length; i++) {
16✔
401
            if (arr[i].file === xmlFile) {
16!
402
                arr.splice(i, 1);
16✔
403
                break;
16✔
404
            }
405
        }
406

407
        this.syncComponentDependencyGraph(arr);
16✔
408
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
16✔
409
    }
410

411
    /**
412
     * Adds a component described in an XML to the set of components that needs to be updated this validation cycle.
413
     * @param xmlFile XML file with <component> tag
414
     */
415
    private addDeferredComponentTypeSymbolCreation(xmlFile: XmlFile) {
416
        const componentKey = this.getComponentKey(xmlFile);
1,482✔
417
        const componentName = xmlFile.componentName?.text;
1,482✔
418
        if (this.componentSymbolsToUpdate.has(componentKey)) {
1,482✔
419
            return;
904✔
420
        }
421
        this.componentSymbolsToUpdate.set(componentKey, componentName);
578✔
422
    }
423

424
    private getComponentKey(xmlFile: XmlFile) {
425
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,945✔
426
    }
427

428
    /**
429
     * Resolves symbol table with the first component in this.components to have the same name as the component in the file
430
     * @param componentKey key getting a component from `this.components`
431
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
432
     */
433
    private updateComponentSymbolInGlobalScope(componentKey: string, componentName: string) {
434
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
530✔
435
        if (!symbolName) {
530✔
436
            return;
7✔
437
        }
438
        const components = this.components[componentKey] || [];
523!
439
        const previousComponentType = this.componentsTable.getSymbolType(symbolName, { flags: SymbolTypeFlag.typetime });
523✔
440
        // Remove any existing symbols that match
441
        this.componentsTable.removeSymbol(symbolName);
523✔
442
        if (components.length > 0) {
523✔
443
            // There is a component that can be added - use it.
444
            const componentScope = components[0].scope;
522✔
445

446
            this.componentsTable.removeSymbol(symbolName);
522✔
447
            componentScope.linkSymbolTable();
522✔
448
            const componentType = componentScope.getComponentType();
522✔
449
            if (componentType) {
522!
450
                this.componentsTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
522✔
451
            }
452
            const typeData = {};
522✔
453
            const isSameAsPrevious = previousComponentType && componentType.isEqual(previousComponentType, typeData);
522✔
454
            const isComponentTypeDifferent = !previousComponentType || isReferenceType(previousComponentType) || !isSameAsPrevious;
522✔
455
            componentScope.unlinkSymbolTable();
522✔
456
            return isComponentTypeDifferent;
522✔
457

458
        }
459
        // There was a previous component type, but no new one, so it's different
460
        return !!previousComponentType;
1✔
461
    }
462

463
    /**
464
     * Adds a reference type to the global symbol table with the first component in this.components to have the same name as the component in the file
465
     * This is so on a first validation, these types can be resolved in teh future (eg. when the actual component is created)
466
     * If we don't add reference types at this top level, they will be created at the file level, and will never get resolved
467
     * @param componentKey key getting a component from `this.components`
468
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
469
     */
470
    private addComponentReferenceType(componentKey: string, componentName: string) {
471
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
530✔
472
        if (!symbolName) {
530✔
473
            return;
7✔
474
        }
475
        const components = this.components[componentKey] || [];
523!
476

477
        if (components.length > 0) {
523✔
478
            // There is a component that can be added,
479
            if (!this.componentsTable.hasSymbol(symbolName, SymbolTypeFlag.typetime)) {
522✔
480
                // it doesn't already exist in the table
481
                const componentRefType = new ReferenceType(symbolName, symbolName, SymbolTypeFlag.typetime, () => this.componentsTable);
3,332✔
482
                if (componentRefType) {
376!
483
                    this.componentsTable.addSymbol(symbolName, {}, componentRefType, SymbolTypeFlag.typetime);
376✔
484
                }
485
            }
486
        } else {
487
            // there is no component. remove from table
488
            this.componentsTable.removeSymbol(symbolName);
1✔
489
        }
490
    }
491

492
    /**
493
     * re-attach the dependency graph with a new key for any component who changed
494
     * their position in their own named array (only matters when there are multiple
495
     * components with the same name)
496
     */
497
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
498
        //reattach every dependency graph
499
        for (let i = 0; i < components.length; i++) {
463✔
500
            const { file, scope } = components[i];
453✔
501

502
            //attach (or re-attach) the dependencyGraph for every component whose position changed
503
            if (file.dependencyGraphIndex !== i) {
453✔
504
                file.dependencyGraphIndex = i;
449✔
505
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
449✔
506
                file.attachDependencyGraph(this.dependencyGraph);
449✔
507
                scope.attachDependencyGraph(this.dependencyGraph);
449✔
508
            }
509
        }
510
    }
511

512
    /**
513
     * Get a list of all files that are included in the project but are not referenced
514
     * by any scope in the program.
515
     */
516
    public getUnreferencedFiles() {
UNCOV
517
        let result = [] as BscFile[];
×
518
        for (let filePath in this.files) {
×
519
            let file = this.files[filePath];
×
520
            //is this file part of a scope
UNCOV
521
            if (!this.getFirstScopeForFile(file)) {
×
522
                //no scopes reference this file. add it to the list
UNCOV
523
                result.push(file);
×
524
            }
525
        }
UNCOV
526
        return result;
×
527
    }
528

529
    /**
530
     * Get the list of errors for the entire program.
531
     */
532
    public getDiagnostics() {
533
        return this.diagnostics.getDiagnostics();
1,230✔
534
    }
535

536
    /**
537
     * Determine if the specified file is loaded in this program right now.
538
     * @param filePath the absolute or relative path to the file
539
     * @param normalizePath should the provided path be normalized before use
540
     */
541
    public hasFile(filePath: string, normalizePath = true) {
2,885✔
542
        return !!this.getFile(filePath, normalizePath);
2,885✔
543
    }
544

545
    /**
546
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
547
     * @param scopeName xml scope names are their `destPath`. Source scope is stored with the key `"source"`
548
     */
549
    public getScopeByName(scopeName: string): Scope | undefined {
550
        if (!scopeName) {
62!
UNCOV
551
            return undefined;
×
552
        }
553
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
554
        //so it's safe to run the standardizePkgPath method
555
        scopeName = s`${scopeName}`;
62✔
556
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
141✔
557
        return this.scopes[key!];
62✔
558
    }
559

560
    /**
561
     * Return all scopes
562
     */
563
    public getScopes() {
564
        return Object.values(this.scopes);
13✔
565
    }
566

567
    /**
568
     * Find the scope for the specified component
569
     */
570
    public getComponentScope(componentName: string) {
571
        return this.getComponent(componentName)?.scope;
930✔
572
    }
573

574
    /**
575
     * Update internal maps with this file reference
576
     */
577
    private assignFile<T extends BscFile = BscFile>(file: T) {
578
        const fileAddEvent: BeforeFileAddEvent = {
2,623✔
579
            file: file,
580
            program: this
581
        };
582

583
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,623✔
584

585
        this.files[file.srcPath.toLowerCase()] = file;
2,623✔
586
        this.destMap.set(file.destPath.toLowerCase(), file);
2,623✔
587

588
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,623✔
589

590
        return file;
2,623✔
591
    }
592

593
    /**
594
     * Remove this file from internal maps
595
     */
596
    private unassignFile<T extends BscFile = BscFile>(file: T) {
597
        delete this.files[file.srcPath.toLowerCase()];
218✔
598
        this.destMap.delete(file.destPath.toLowerCase());
218✔
599
        return file;
218✔
600
    }
601

602
    /**
603
     * Load a file into the program. If that file already exists, it is replaced.
604
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
605
     * @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:/`)
606
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
607
     */
608
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileData?: FileData): T;
609
    /**
610
     * Load a file into the program. If that file already exists, it is replaced.
611
     * @param fileEntry an object that specifies src and dest for the file.
612
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
613
     */
614
    public setFile<T extends BscFile>(fileEntry: FileObj, fileData: FileData): T;
615
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileData: FileData): T {
616
        //normalize the file paths
617
        const { srcPath, destPath } = this.getPaths(fileParam, this.options.rootDir);
2,619✔
618

619
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
2,619✔
620
            //if the file is already loaded, remove it
621
            if (this.hasFile(srcPath)) {
2,619✔
622
                this.removeFile(srcPath, true, true);
198✔
623
            }
624

625
            const data = new LazyFileData(fileData);
2,619✔
626

627
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,619✔
628

629
            this.plugins.emit('beforeProvideFile', event);
2,619✔
630
            this.plugins.emit('provideFile', event);
2,619✔
631
            this.plugins.emit('afterProvideFile', event);
2,619✔
632

633
            //if no files were provided, create a AssetFile to represent it.
634
            if (event.files.length === 0) {
2,619✔
635
                event.files.push(
24✔
636
                    this.fileFactory.AssetFile({
637
                        srcPath: event.srcPath,
638
                        destPath: event.destPath,
639
                        pkgPath: event.destPath,
640
                        data: data
641
                    })
642
                );
643
            }
644

645
            //find the file instance for the srcPath that triggered this action.
646
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
2,619✔
647

648
            if (!primaryFile) {
2,619!
UNCOV
649
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
650
                    type: x.type,
651
                    srcPath: x.srcPath,
652
                    destPath: x.destPath
653
                })))}`);
654
            }
655

656
            //link the virtual files to the primary file
657
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
2,619!
658

659
            for (const file of event.files) {
2,619✔
660
                file.srcPath = s(file.srcPath);
2,623✔
661
                if (file.destPath) {
2,623!
662
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
2,623✔
663
                }
664
                if (file.pkgPath) {
2,623✔
665
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
2,619✔
666
                } else {
667
                    file.pkgPath = file.destPath;
4✔
668
                }
669
                file.excludeFromOutput = file.excludeFromOutput === true;
2,623✔
670

671
                //set the dependencyGraph key for every file to its destPath
672
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,623✔
673

674
                this.assignFile(file);
2,623✔
675

676
                //register a callback anytime this file's dependencies change
677
                if (typeof file.onDependenciesChanged === 'function') {
2,623✔
678
                    file.disposables ??= [];
2,591!
679
                    file.disposables.push(
2,591✔
680
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
681
                    );
682
                }
683

684
                //register this file (and its dependencies) with the dependency graph
685
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
2,623✔
686

687
                //if this is a `source` file, add it to the source scope's dependency list
688
                if (this.isSourceBrsFile(file)) {
2,623✔
689
                    this.createSourceScope();
1,745✔
690
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
1,745✔
691
                }
692

693
                //if this is an xml file in the components folder, register it as a component
694
                if (this.isComponentsXmlFile(file)) {
2,623✔
695
                    //create a new scope for this xml file
696
                    let scope = new XmlScope(file, this);
447✔
697
                    this.addScope(scope);
447✔
698

699
                    //register this componet now that we have parsed it and know its component name
700
                    this.registerComponent(file, scope);
447✔
701

702
                    //notify plugins that the scope is created and the component is registered
703
                    this.plugins.emit('afterScopeCreate', {
447✔
704
                        program: this,
705
                        scope: scope
706
                    });
707
                }
708
            }
709

710
            return primaryFile;
2,619✔
711
        });
712
        return file as T;
2,619✔
713
    }
714

715
    /**
716
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
717
     * @param fileParam an object representing file paths
718
     * @param rootDir must be a pre-normalized path
719
     */
720
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
721
        let srcPath: string | undefined;
722
        let destPath: string | undefined;
723

724
        assert.ok(fileParam, 'fileParam is required');
2,840✔
725

726
        //lift the path vars from the incoming param
727
        if (typeof fileParam === 'string') {
2,840✔
728
            fileParam = this.removePkgPrefix(fileParam);
2,368✔
729
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,368✔
730
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,368✔
731
        } else {
732
            let param: any = fileParam;
472✔
733

734
            if (param.src) {
472✔
735
                srcPath = s`${param.src}`;
471✔
736
            }
737
            if (param.srcPath) {
472!
UNCOV
738
                srcPath = s`${param.srcPath}`;
×
739
            }
740
            if (param.dest) {
472✔
741
                destPath = s`${this.removePkgPrefix(param.dest)}`;
471✔
742
            }
743
            if (param.pkgPath) {
472!
UNCOV
744
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
745
            }
746
        }
747

748
        //if there's no srcPath, use the destPath to build an absolute srcPath
749
        if (!srcPath) {
2,840✔
750
            srcPath = s`${rootDir}/${destPath}`;
1✔
751
        }
752
        //coerce srcPath to an absolute path
753
        if (!path.isAbsolute(srcPath)) {
2,840✔
754
            srcPath = util.standardizePath(srcPath);
1✔
755
        }
756

757
        //if destPath isn't set, compute it from the other paths
758
        if (!destPath) {
2,840✔
759
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
760
        }
761

762
        assert.ok(srcPath, 'fileEntry.src is required');
2,840✔
763
        assert.ok(destPath, 'fileEntry.dest is required');
2,840✔
764

765
        return {
2,840✔
766
            srcPath: srcPath,
767
            //remove leading slash
768
            destPath: destPath.replace(/^[\/\\]+/, '')
769
        };
770
    }
771

772
    /**
773
     * Remove any leading `pkg:/` found in the path
774
     */
775
    private removePkgPrefix(path: string) {
776
        return path.replace(/^pkg:\//i, '');
2,839✔
777
    }
778

779
    /**
780
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
781
     */
782
    private isSourceBrsFile(file: BscFile) {
783
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
2,841✔
784
    }
785

786
    /**
787
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
788
     */
789
    private isComponentsXmlFile(file: BscFile): file is XmlFile {
790
        return isXmlFile(file) && !!/^(pkg:\/)?components[\/\\]/.exec(file.destPath);
2,623✔
791
    }
792

793
    /**
794
     * Ensure source scope is created.
795
     * Note: automatically called internally, and no-op if it exists already.
796
     */
797
    public createSourceScope() {
798
        if (!this.scopes.source) {
2,561✔
799
            const sourceScope = new Scope('source', this, 'scope:source');
1,670✔
800
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,670✔
801
            this.addScope(sourceScope);
1,670✔
802
            this.plugins.emit('afterScopeCreate', {
1,670✔
803
                program: this,
804
                scope: sourceScope
805
            });
806
        }
807
    }
808

809
    /**
810
     * Remove a set of files from the program
811
     * @param srcPaths can be an array of srcPath or destPath strings
812
     * @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
813
     */
814
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
815
        for (let srcPath of srcPaths) {
1✔
816
            this.removeFile(srcPath, normalizePath);
1✔
817
        }
818
    }
819

820
    /**
821
     * Remove a file from the program
822
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
823
     * @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
824
     */
825
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
32✔
826
        this.logger.debug('Program.removeFile()', filePath);
216✔
827
        const paths = this.getPaths(filePath, this.options.rootDir);
216✔
828

829
        //there can be one or more File entries for a single srcPath, so get all of them and remove them all
830
        const files = this.fileClusters.get(paths.srcPath?.toLowerCase()) ?? [this.getFile(filePath, normalizePath)];
216!
831

832
        for (const file of files) {
216✔
833
            //if a file has already been removed, nothing more needs to be done here
834
            if (!file || !this.hasFile(file.srcPath)) {
219✔
835
                continue;
1✔
836
            }
837
            this.diagnostics.clearForFile(file.srcPath);
218✔
838

839
            const event: BeforeFileRemoveEvent = { file: file, program: this };
218✔
840
            this.plugins.emit('beforeFileRemove', event);
218✔
841

842
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
843
            let scope = this.scopes[file.destPath];
218✔
844
            if (scope) {
218✔
845
                this.logger.debug('Removing associated scope', scope.name);
16✔
846
                const scopeDisposeEvent = {
16✔
847
                    program: this,
848
                    scope: scope
849
                };
850
                this.plugins.emit('beforeScopeDispose', scopeDisposeEvent);
16✔
851
                this.plugins.emit('onScopeDispose', scopeDisposeEvent);
16✔
852
                scope.dispose();
16✔
853
                //notify dependencies of this scope that it has been removed
854
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
16✔
855
                this.removeScope(this.scopes[file.destPath]);
16✔
856
                this.plugins.emit('afterScopeDispose', scopeDisposeEvent);
16✔
857
            }
858
            //remove the file from the program
859
            this.unassignFile(file);
218✔
860

861
            this.dependencyGraph.remove(file.dependencyGraphKey);
218✔
862

863
            //if this is a pkg:/source file, notify the `source` scope that it has changed
864
            if (this.isSourceBrsFile(file)) {
218✔
865
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
147✔
866
            }
867
            if (isBrsFile(file)) {
218✔
868
                this.logger.debug('Removing file symbol info', file.srcPath);
195✔
869

870
                if (!keepSymbolInformation) {
195✔
871
                    this.fileSymbolInformation.delete(file.pkgPath);
12✔
872
                }
873
                this.crossScopeValidation.clearResolutionsForFile(file);
195✔
874
            }
875

876
            this.diagnostics.clearForFile(file.srcPath);
218✔
877

878
            //if this is a component, remove it from our components map
879
            if (isXmlFile(file)) {
218✔
880
                this.logger.debug('Unregistering component', file.srcPath);
16✔
881

882
                this.unregisterComponent(file);
16✔
883
            }
884
            this.logger.debug('Disposing file', file.srcPath);
218✔
885

886
            //dispose any disposable things on the file
887
            for (const disposable of file?.disposables ?? []) {
218!
888
                disposable();
211✔
889
            }
890
            //dispose file
891
            file?.dispose?.();
218!
892

893
            this.plugins.emit('afterFileRemove', event);
218✔
894
        }
895
    }
896

897
    public crossScopeValidation = new CrossScopeValidator(this);
1,945✔
898

899
    private isFirstValidation = true;
1,945✔
900

901
    /**
902
     * Counter used to track which validation run is being logged
903
     */
904
    private validationRunSequence = 1;
1,945✔
905

906
    /**
907
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
908
     */
909
    private validationMinSyncDuration = 75;
1,945✔
910

911
    private validatePromise: Promise<void> | undefined;
912

913

914
    private validationDetails: {
1,945✔
915
        brsFilesValidated: BrsFile[];
916
        xmlFilesValidated: XmlFile[];
917
        changedSymbols: Map<SymbolTypeFlag, Set<string>>;
918
        changedComponentTypes: string[];
919
        scopesToValidate: Scope[];
920
        filesToBeValidatedInScopeContext: Set<BscFile>;
921

922
    } = {
923
            brsFilesValidated: [],
924
            xmlFilesValidated: [],
925
            changedSymbols: new Map<SymbolTypeFlag, Set<string>>(),
926
            changedComponentTypes: [],
927
            scopesToValidate: [],
928
            filesToBeValidatedInScopeContext: new Set<BscFile>()
929
        };
930

931
    /**
932
     * Traverse the entire project, and validate all scopes
933
     */
934
    public validate(): void;
935
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
936
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
937
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
938
        const validationRunId = this.validationRunSequence++;
1,512✔
939

940
        let previousValidationPromise = this.validatePromise;
1,512✔
941
        const deferred = new Deferred();
1,512✔
942

943
        if (options?.async) {
1,512✔
944
            //we're async, so create a new promise chain to resolve after this validation is done
945
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
127✔
946
                return deferred.promise;
127✔
947
            });
948

949
            //we are not async but there's a pending promise, then we cannot run this validation
950
        } else if (previousValidationPromise !== undefined) {
1,385!
UNCOV
951
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
952
        }
953

954
        let beforeProgramValidateWasEmitted = false;
1,512✔
955

956
        const brsFilesValidated: BrsFile[] = this.validationDetails.brsFilesValidated;
1,512✔
957
        const xmlFilesValidated: XmlFile[] = this.validationDetails.xmlFilesValidated;
1,512✔
958
        const changedSymbols = this.validationDetails.changedSymbols;
1,512✔
959
        const changedComponentTypes = this.validationDetails.changedComponentTypes;
1,512✔
960
        const scopesToValidate = this.validationDetails.scopesToValidate;
1,512✔
961
        const filesToBeValidatedInScopeContext = this.validationDetails.filesToBeValidatedInScopeContext;
1,512✔
962

963
        //validate every file
964

965
        let logValidateEnd = (status?: string) => { };
1,512✔
966

967
        //will be populated later on during the correspnding sequencer event
968
        let filesToProcess: BscFile[];
969

970
        const sequencer = new Sequencer({
1,512✔
971
            name: 'program.validate',
972
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
9,072✔
973
            minSyncDuration: this.validationMinSyncDuration
974
        });
975
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
976
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
977
        sequencer
1,512✔
978
            .once('wait for previous run', () => {
979
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
980
                if (options?.async) {
1,512✔
981
                    return previousValidationPromise;
127✔
982
                }
983
            })
984
            .once('before and on programValidate', () => {
985
                logValidateEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
1,509!
986
                this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,509✔
987
                this.plugins.emit('beforeProgramValidate', {
1,509✔
988
                    program: this
989
                });
990
                beforeProgramValidateWasEmitted = true;
1,509✔
991
                this.plugins.emit('onProgramValidate', {
1,509✔
992
                    program: this
993
                });
994
            })
995
            //handle some component symbol stuff
996
            .forEach('addDeferredComponentTypeSymbolCreation',
997
                () => {
998
                    filesToProcess = Object.values(this.files).sort(firstBy(x => x.srcPath)).filter(x => !x.isValidated);
4,252✔
999
                    for (const file of filesToProcess) {
1,509✔
1000
                        filesToBeValidatedInScopeContext.add(file);
2,189✔
1001
                    }
1002

1003
                    //return the list of files that need to be processed
1004
                    return filesToProcess;
1,509✔
1005
                }, (file) => {
1006
                    // cast a wide net for potential changes in components
1007
                    if (isXmlFile(file)) {
2,188✔
1008
                        this.addDeferredComponentTypeSymbolCreation(file);
393✔
1009
                    } else if (isBrsFile(file)) {
1,795!
1010
                        for (const scope of this.getScopesForFile(file)) {
1,795✔
1011
                            if (isXmlScope(scope)) {
2,071✔
1012
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
626✔
1013
                            }
1014
                        }
1015
                    }
1016
                }
1017
            )
1018
            .once('addComponentReferenceTypes', () => {
1019
                // Create reference component types for any component that changes
1020
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,506✔
1021
                    this.addComponentReferenceType(componentKey, componentName);
530✔
1022
                }
1023
            })
1024
            .forEach('beforeFileValidate', () => filesToProcess, (file) => {
1,506✔
1025
                //run the beforeFilevalidate event for every unvalidated file
1026
                this.plugins.emit('beforeFileValidate', {
2,188✔
1027
                    program: this,
1028
                    file: file
1029
                });
1030
            })
1031
            .forEach('onFileValidate', () => filesToProcess, (file) => {
1,506✔
1032
                //run the onFileValidate event for every unvalidated file
1033
                this.plugins.emit('onFileValidate', {
2,188✔
1034
                    program: this,
1035
                    file: file
1036
                });
1037
                file.isValidated = true;
2,188✔
1038
                if (isBrsFile(file)) {
2,188✔
1039
                    brsFilesValidated.push(file);
1,795✔
1040
                } else if (isXmlFile(file)) {
393!
1041
                    xmlFilesValidated.push(file);
393✔
1042
                }
1043
            })
1044
            .forEach('afterFileValidate', () => filesToProcess, (file) => {
1,506✔
1045
                //run the onFileValidate event for every unvalidated file
1046
                this.plugins.emit('afterFileValidate', {
2,188✔
1047
                    program: this,
1048
                    file: file
1049
                });
1050
            })
1051
            .once('Build component types for any component that changes', () => {
1052
                this.logger.time(LogLevel.info, ['Build component types'], () => {
1,505✔
1053
                    for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,505✔
1054
                        if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
530✔
1055
                            changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
384✔
1056
                        }
1057
                    }
1058
                    this.componentSymbolsToUpdate.clear();
1,505✔
1059
                });
1060
            })
1061
            .once('track and update type-time and runtime symbol dependencies and changes', () => {
1062
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,505!
1063
                    if (isBrsFile(f)) {
2,188✔
1064
                        return f.providedSymbols.changes;
1,795✔
1065
                    }
1066
                    return null;
393✔
1067
                }).filter(x => x);
2,188✔
1068

1069
                // update the map of typetime dependencies
1070
                for (const file of brsFilesValidated) {
1,505✔
1071
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,795✔
1072
                        // clear existing dependencies
1073
                        for (const values of this.symbolDependencies.values()) {
671✔
1074
                            values.delete(symbolName);
62✔
1075
                        }
1076

1077
                        // map types to the set of types that depend upon them
1078
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
671!
1079
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
185✔
1080
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
185✔
1081
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
163✔
1082
                            }
1083
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
185✔
1084
                            symbolsDependentUpon.add(symbolName);
185✔
1085
                        }
1086
                    }
1087
                }
1088

1089
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,505✔
1090
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,590✔
1091
                    const changedSymbolSet = new Set<string>();
3,010✔
1092
                    for (const changeSet of changedSymbolsSetArr) {
3,010✔
1093
                        for (const change of changeSet) {
3,590✔
1094
                            changedSymbolSet.add(change);
3,568✔
1095
                        }
1096
                    }
1097
                    if (!changedSymbols.has(flag)) {
3,010✔
1098
                        changedSymbols.set(flag, changedSymbolSet);
3,006✔
1099
                    } else {
1100
                        changedSymbols.set(flag, new Set([...changedSymbols.get(flag), ...changedSymbolSet]));
4✔
1101
                    }
1102
                }
1103

1104
                // update changed symbol set with any changed component
1105
                for (const changedComponentType of changedComponentTypes) {
1,505✔
1106
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
384✔
1107
                }
1108

1109
                // Add any additional types that depend on a changed type
1110
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1111
                const dependentTypesChanged = new Set<string>();
1,505✔
1112
                let foundDependentTypes = false;
1,505✔
1113
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,505✔
1114
                do {
1,505✔
1115
                    foundDependentTypes = false;
1,511✔
1116
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
1,511✔
1117
                    for (const changedSymbol of allChangedTypesSofar) {
1,511✔
1118
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,058✔
1119
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,058✔
1120
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
189✔
1121
                                foundDependentTypes = true;
6✔
1122
                                dependentTypesChanged.add(symbolName);
6✔
1123
                            }
1124
                        }
1125
                    }
1126
                } while (foundDependentTypes);
1127

1128
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedSymbols.get(SymbolTypeFlag.typetime), ...changedTypeSymbols, ...dependentTypesChanged]));
1,505✔
1129

1130
                // can reset filesValidatedList, because they are no longer needed
1131
                this.validationDetails.brsFilesValidated = [];
1,505✔
1132
                this.validationDetails.xmlFilesValidated = [];
1,505✔
1133
            })
1134
            .once('tracks changed symbols and prepares files and scopes for validation.', () => {
1135
                if (this.options.logLevel === LogLevel.debug) {
1,505!
UNCOV
1136
                    const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
1137
                    this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
1138
                    const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
1139
                    this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1140
                }
1141

1142
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,505✔
1143
                this.crossScopeValidation.buildComponentsMap();
1,505✔
1144
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,505✔
1145
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,505✔
1146
                for (const file of filesToRevalidate) {
1,505✔
1147
                    filesToBeValidatedInScopeContext.add(file);
420✔
1148
                }
1149

1150
                this.currentScopeValidationOptions = {
1,505✔
1151
                    filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1152
                    changedSymbols: changedSymbols,
1153
                    changedFiles: Array.from(filesToBeValidatedInScopeContext),
1154
                    initialValidation: this.isFirstValidation
1155
                };
1156

1157
                //can reset changedComponent types
1158
                this.validationDetails.changedComponentTypes = [];
1,505✔
1159
            })
1160
            .forEach('invalidate affected scopes', () => filesToBeValidatedInScopeContext, (file) => {
1,505✔
1161
                if (isBrsFile(file)) {
2,318✔
1162
                    file.validationSegmenter.unValidateAllSegments();
1,925✔
1163
                    for (const scope of this.getScopesForFile(file)) {
1,925✔
1164
                        scope.invalidate();
2,202✔
1165
                    }
1166
                }
1167
            })
1168
            .forEach('validate scopes', () => this.getSortedScopeNames(), (scopeName) => {
1,505✔
1169
                //sort the scope names so we get consistent results
1170
                let scope = this.scopes[scopeName];
3,442✔
1171
                if (scope.shouldValidate(this.currentScopeValidationOptions)) {
3,442✔
1172
                    scopesToValidate.push(scope);
1,878✔
1173
                    this.plugins.emit('beforeScopeValidate', {
1,878✔
1174
                        program: this,
1175
                        scope: scope
1176
                    });
1177
                }
1178
            })
1179
            .forEach('validate scope', () => this.getSortedScopeNames(), (scopeName) => {
1,503✔
1180
                //sort the scope names so we get consistent results
1181
                let scope = this.scopes[scopeName];
3,437✔
1182
                scope.validate(this.currentScopeValidationOptions);
3,437✔
1183
            })
1184
            .forEach('afterScopeValidate', () => scopesToValidate, (scope) => {
1,499✔
1185
                this.plugins.emit('afterScopeValidate', {
1,874✔
1186
                    program: this,
1187
                    scope: scope
1188
                });
1189
            })
1190
            .once('detect duplicate component names', () => {
1191
                this.detectDuplicateComponentNames();
1,498✔
1192
                this.isFirstValidation = false;
1,498✔
1193

1194
                // can reset other validation details
1195
                this.validationDetails.changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,498✔
1196
                this.validationDetails.scopesToValidate = [];
1,498✔
1197
                this.validationDetails.filesToBeValidatedInScopeContext = new Set<BscFile>();
1,498✔
1198

1199
            })
1200
            .onCancel(() => {
1201
                logValidateEnd('cancelled');
14✔
1202
            })
1203
            .onSuccess(() => {
1204
                logValidateEnd();
1,498✔
1205
            })
1206
            .onComplete(() => {
1207
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
1208
                if (beforeProgramValidateWasEmitted) {
1,512✔
1209
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
1,509✔
1210
                    this.plugins.emit('afterProgramValidate', {
1,509✔
1211
                        program: this,
1212
                        wasCancelled: wasCancelled
1213
                    });
1214
                }
1215

1216
                //log all the sequencer timing metrics if `info` logging is enabled
1217
                this.logger.info(
1,512✔
1218
                    sequencer.formatMetrics({
1219
                        header: 'Program.validate metrics:',
1220
                        //only include loop iterations if `debug` logging is enabled
1221
                        includeLoopIterations: this.logger.isLogLevelEnabled(LogLevel.debug)
1222
                    })
1223
                );
1224

1225
                //regardless of the success of the validation, mark this run as complete
1226
                deferred.resolve();
1,512✔
1227
                //clear the validatePromise which means we're no longer running a validation
1228
                this.validatePromise = undefined;
1,512✔
1229
            });
1230

1231
        //run the sequencer in async mode if enabled
1232
        if (options?.async) {
1,512✔
1233
            return sequencer.run();
127✔
1234

1235
            //run the sequencer in sync mode
1236
        } else {
1237
            return sequencer.runSync();
1,385✔
1238
        }
1239
    }
1240

1241
    protected logValidationMetrics(metrics: Record<string, number | string>) {
UNCOV
1242
        let logs = [] as string[];
×
1243
        for (const key in metrics) {
×
1244
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
×
1245
        }
UNCOV
1246
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
×
1247
    }
1248

1249
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1250
        const scopesForCrossScopeValidation = [];
1,505✔
1251
        for (let scopeName of this.getSortedScopeNames()) {
1,505✔
1252
            let scope = this.scopes[scopeName];
3,442✔
1253
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,442✔
1254
                scopesForCrossScopeValidation.push(scope);
1,894✔
1255
            }
1256
        }
1257
        return scopesForCrossScopeValidation;
1,505✔
1258
    }
1259

1260
    /**
1261
     * Flag all duplicate component names
1262
     */
1263
    private detectDuplicateComponentNames() {
1264
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,498✔
1265
            const file = this.files[filePath];
2,563✔
1266
            //if this is an XmlFile, and it has a valid `componentName` property
1267
            if (isXmlFile(file) && file.componentName?.text) {
2,563✔
1268
                let lowerName = file.componentName.text.toLowerCase();
557✔
1269
                if (!map[lowerName]) {
557✔
1270
                    map[lowerName] = [];
554✔
1271
                }
1272
                map[lowerName].push(file);
557✔
1273
            }
1274
            return map;
2,563✔
1275
        }, {});
1276

1277
        for (let name in componentsByName) {
1,498✔
1278
            const xmlFiles = componentsByName[name];
554✔
1279
            //add diagnostics for every duplicate component with this name
1280
            if (xmlFiles.length > 1) {
554✔
1281
                for (let xmlFile of xmlFiles) {
3✔
1282
                    const { componentName } = xmlFile;
6✔
1283
                    this.diagnostics.register({
6✔
1284
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1285
                        location: xmlFile.componentName.location,
1286
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1287
                            return {
6✔
1288
                                location: x.componentName.location,
1289
                                message: 'Also defined here'
1290
                            };
1291
                        })
1292
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1293
                }
1294
            }
1295
        }
1296
    }
1297

1298
    /**
1299
     * Get the files for a list of filePaths
1300
     * @param filePaths can be an array of srcPath or a destPath strings
1301
     * @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
1302
     */
1303
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1304
        return filePaths
29✔
1305
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1306
            .filter(file => file !== undefined) as T[];
39✔
1307
    }
1308

1309
    private getFilePathCache = new Map<string, { path: string; isDestMap?: boolean }>();
1,945✔
1310

1311
    /**
1312
     * Get the file at the given path
1313
     * @param filePath can be a srcPath or a destPath
1314
     * @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
1315
     */
1316
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
19,607✔
1317
        if (this.getFilePathCache.has(filePath)) {
26,493✔
1318
            const cachedFilePath = this.getFilePathCache.get(filePath);
15,653✔
1319
            if (cachedFilePath.isDestMap) {
15,653✔
1320
                return this.destMap.get(
12,989✔
1321
                    cachedFilePath.path
1322
                ) as T;
1323
            }
1324
            return this.files[
2,664✔
1325
                cachedFilePath.path
1326
            ] as T;
1327
        }
1328
        if (typeof filePath !== 'string') {
10,840✔
1329
            return undefined;
3,639✔
1330
            //is the path absolute (or the `virtual:` prefix)
1331
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
7,201✔
1332
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,451!
1333
            this.getFilePathCache.set(filePath, { path: standardizedPath });
3,451✔
1334

1335
            return this.files[
3,451✔
1336
                standardizedPath
1337
            ] as T;
1338
        } else if (util.isUriLike(filePath)) {
3,750✔
1339
            const path = URI.parse(filePath).fsPath;
572✔
1340
            const standardizedPath = (normalizePath ? util.standardizePath(path) : path).toLowerCase();
572!
1341
            this.getFilePathCache.set(filePath, { path: standardizedPath });
572✔
1342

1343
            return this.files[
572✔
1344
                standardizedPath
1345
            ] as T;
1346
        } else {
1347
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,178✔
1348
            this.getFilePathCache.set(filePath, { path: standardizedPath, isDestMap: true });
3,178✔
1349
            return this.destMap.get(
3,178✔
1350
                standardizedPath
1351
            ) as T;
1352
        }
1353
    }
1354

1355
    private sortedScopeNames: string[] = undefined;
1,945✔
1356

1357
    /**
1358
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1359
     */
1360
    private getSortedScopeNames() {
1361
        if (!this.sortedScopeNames) {
13,003✔
1362
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,432✔
1363
                if (a === 'global') {
2,069!
UNCOV
1364
                    return -1;
×
1365
                } else if (b === 'global') {
2,069✔
1366
                    return 1;
1,394✔
1367
                }
1368
                if (a === 'source') {
675✔
1369
                    return -1;
28✔
1370
                } else if (b === 'source') {
647✔
1371
                    return 1;
151✔
1372
                }
1373
                if (a < b) {
496✔
1374
                    return -1;
197✔
1375
                } else if (b < a) {
299!
1376
                    return 1;
299✔
1377
                }
UNCOV
1378
                return 0;
×
1379
            });
1380
        }
1381
        return this.sortedScopeNames;
13,003✔
1382
    }
1383

1384
    /**
1385
     * Get a list of all scopes the file is loaded into
1386
     * @param file the file
1387
     */
1388
    public getScopesForFile(file: BscFile | string) {
1389
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,256✔
1390

1391
        let result = [] as Scope[];
4,256✔
1392
        if (resolvedFile) {
4,256✔
1393
            const scopeKeys = this.getSortedScopeNames();
4,255✔
1394
            for (let key of scopeKeys) {
4,255✔
1395
                let scope = this.scopes[key];
39,952✔
1396

1397
                if (scope.hasFile(resolvedFile)) {
39,952✔
1398
                    result.push(scope);
4,824✔
1399
                }
1400
            }
1401
        }
1402
        return result;
4,256✔
1403
    }
1404

1405
    /**
1406
     * Get the first found scope for a file.
1407
     */
1408
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1409
        const scopeKeys = this.getSortedScopeNames();
4,235✔
1410
        for (let key of scopeKeys) {
4,235✔
1411
            let scope = this.scopes[key];
18,924✔
1412

1413
            if (scope.hasFile(file)) {
18,924✔
1414
                return scope;
3,088✔
1415
            }
1416
        }
1417
    }
1418

1419
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1420
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1421
        const filesSearched = new Set<BrsFile>();
39✔
1422
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1423
        let lowerName = name?.toLowerCase();
39!
1424

1425
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1426
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1427
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1428
                if (!results.has(statement)) {
36!
1429
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1430
                }
1431
            }
1432
        }
1433

1434
        //look through all files in scope for matches
1435
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1436
            for (const file of scope.getAllFiles()) {
39✔
1437
                //skip non-brs files, or files we've already processed
1438
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1439
                    continue;
3✔
1440
                }
1441
                filesSearched.add(file);
42✔
1442

1443
                file.ast.walk(createVisitor({
42✔
1444
                    FunctionStatement: (statement: FunctionStatement) => {
1445
                        addToResults(statement, file);
95✔
1446
                    },
1447
                    MethodStatement: (statement: MethodStatement) => {
1448
                        addToResults(statement, file);
3✔
1449
                    }
1450
                }), {
1451
                    walkMode: WalkMode.visitStatements
1452
                });
1453
            }
1454
        }
1455
        return [...results.values()];
39✔
1456
    }
1457

1458
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1459
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1460
        const filesSearched = new Set<BrsFile>();
14✔
1461

1462
        //get all function names for the xml file and parents
1463
        let funcNames = new Set<string>();
14✔
1464
        let currentScope = scope;
14✔
1465
        while (isXmlScope(currentScope)) {
14✔
1466
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1467
                if (!filterName || name === filterName) {
20!
1468
                    funcNames.add(name);
20✔
1469
                }
1470
            }
1471
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1472
        }
1473

1474
        //look through all files in scope for matches
1475
        for (const file of scope.getOwnFiles()) {
14✔
1476
            //skip non-brs files, or files we've already processed
1477
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1478
                continue;
14✔
1479
            }
1480
            filesSearched.add(file);
14✔
1481

1482
            file.ast.walk(createVisitor({
14✔
1483
                FunctionStatement: (statement: FunctionStatement) => {
1484
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1485
                        if (!results.has(statement)) {
19!
1486
                            results.set(statement, { item: statement, file: file });
19✔
1487
                        }
1488
                    }
1489
                }
1490
            }), {
1491
                walkMode: WalkMode.visitStatements
1492
            });
1493
        }
1494
        return [...results.values()];
14✔
1495
    }
1496

1497
    /**
1498
     * Find all available completion items at the given position
1499
     * @param filePath can be a srcPath or a destPath
1500
     * @param position the position (line & column) where completions should be found
1501
     */
1502
    public getCompletions(filePath: string, position: Position) {
1503
        let file = this.getFile(filePath);
126✔
1504
        if (!file) {
126!
UNCOV
1505
            return [];
×
1506
        }
1507

1508
        const event: ProvideCompletionsEvent = {
126✔
1509
            program: this,
1510
            file: file,
1511
            scopes: this.getScopesForFile(file),
1512
            position: position,
1513
            completions: []
1514
        };
1515

1516
        this.plugins.emit('beforeProvideCompletions', event);
126✔
1517

1518
        this.plugins.emit('provideCompletions', event);
126✔
1519

1520
        this.plugins.emit('afterProvideCompletions', event);
126✔
1521

1522
        return event.completions;
126✔
1523
    }
1524

1525
    /**
1526
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1527
     */
1528
    public getWorkspaceSymbols() {
1529
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1530
            program: this,
1531
            workspaceSymbols: []
1532
        };
1533
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1534
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1535
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1536
        return event.workspaceSymbols;
22✔
1537
    }
1538

1539
    /**
1540
     * Given a position in a file, if the position is sitting on some type of identifier,
1541
     * go to the definition of that identifier (where this thing was first defined)
1542
     */
1543
    public getDefinition(srcPath: string, position: Position): Location[] {
1544
        let file = this.getFile(srcPath);
18✔
1545
        if (!file) {
18!
UNCOV
1546
            return [];
×
1547
        }
1548

1549
        const event: ProvideDefinitionEvent = {
18✔
1550
            program: this,
1551
            file: file,
1552
            position: position,
1553
            definitions: []
1554
        };
1555

1556
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1557
        this.plugins.emit('provideDefinition', event);
18✔
1558
        this.plugins.emit('afterProvideDefinition', event);
18✔
1559
        return event.definitions;
18✔
1560
    }
1561

1562
    /**
1563
     * Get hover information for a file and position
1564
     */
1565
    public getHover(srcPath: string, position: Position): Hover[] {
1566
        let file = this.getFile(srcPath);
69✔
1567
        let result: Hover[];
1568
        if (file) {
69!
1569
            const event = {
69✔
1570
                program: this,
1571
                file: file,
1572
                position: position,
1573
                scopes: this.getScopesForFile(file),
1574
                hovers: []
1575
            } as ProvideHoverEvent;
1576
            this.plugins.emit('beforeProvideHover', event);
69✔
1577
            this.plugins.emit('provideHover', event);
69✔
1578
            this.plugins.emit('afterProvideHover', event);
69✔
1579
            result = event.hovers;
69✔
1580
        }
1581

1582
        return result ?? [];
69!
1583
    }
1584

1585
    /**
1586
     * Get full list of document symbols for a file
1587
     * @param srcPath path to the file
1588
     */
1589
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1590
        let file = this.getFile(srcPath);
24✔
1591
        if (file) {
24!
1592
            const event: ProvideDocumentSymbolsEvent = {
24✔
1593
                program: this,
1594
                file: file,
1595
                documentSymbols: []
1596
            };
1597
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1598
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1599
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1600
            return event.documentSymbols;
24✔
1601
        } else {
UNCOV
1602
            return undefined;
×
1603
        }
1604
    }
1605

1606
    /**
1607
     * Compute code actions for the given file and range
1608
     */
1609
    public getCodeActions(srcPath: string, range: Range) {
1610
        const codeActions = [] as CodeAction[];
12✔
1611
        const file = this.getFile(srcPath);
12✔
1612
        if (file) {
12✔
1613
            const fileUri = util.pathToUri(file?.srcPath);
11!
1614
            const diagnostics = this
11✔
1615
                //get all current diagnostics (filtered by diagnostic filters)
1616
                .getDiagnostics()
1617
                //only keep diagnostics related to this file
1618
                .filter(x => x.location?.uri === fileUri)
21!
1619
                //only keep diagnostics that touch this range
1620
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
12✔
1621

1622
            const scopes = this.getScopesForFile(file);
11✔
1623

1624
            this.plugins.emit('onGetCodeActions', {
11✔
1625
                program: this,
1626
                file: file,
1627
                range: range,
1628
                diagnostics: diagnostics,
1629
                scopes: scopes,
1630
                codeActions: codeActions
1631
            });
1632
        }
1633
        return codeActions;
12✔
1634
    }
1635

1636
    /**
1637
     * Get semantic tokens for the specified file
1638
     */
1639
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1640
        const file = this.getFile(srcPath);
24✔
1641
        if (file) {
24!
1642
            const result = [] as SemanticToken[];
24✔
1643
            this.plugins.emit('onGetSemanticTokens', {
24✔
1644
                program: this,
1645
                file: file,
1646
                scopes: this.getScopesForFile(file),
1647
                semanticTokens: result
1648
            });
1649
            return result;
24✔
1650
        }
1651
    }
1652

1653
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1654
        let file: BrsFile = this.getFile(filePath);
185✔
1655
        if (!file || !isBrsFile(file)) {
185✔
1656
            return [];
3✔
1657
        }
1658
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1659
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1660
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1661
    }
1662

1663
    public getReferences(srcPath: string, position: Position): Location[] {
1664
        //find the file
1665
        let file = this.getFile(srcPath);
4✔
1666

1667
        const event: ProvideReferencesEvent = {
4✔
1668
            program: this,
1669
            file: file,
1670
            position: position,
1671
            references: []
1672
        };
1673

1674
        this.plugins.emit('beforeProvideReferences', event);
4✔
1675
        this.plugins.emit('provideReferences', event);
4✔
1676
        this.plugins.emit('afterProvideReferences', event);
4✔
1677

1678
        return event.references;
4✔
1679
    }
1680

1681
    /**
1682
     * Transpile a single file and get the result as a string.
1683
     * This does not write anything to the file system.
1684
     *
1685
     * This should only be called by `LanguageServer`.
1686
     * Internal usage should call `_getTranspiledFileContents` instead.
1687
     * @param filePath can be a srcPath or a destPath
1688
     */
1689
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1690
        const file = this.getFile(filePath);
320✔
1691

1692
        return this.getTranspiledFileContentsPipeline.run(async () => {
320✔
1693

1694
            const result = {
320✔
1695
                destPath: file.destPath,
1696
                pkgPath: file.pkgPath,
1697
                srcPath: file.srcPath
1698
            } as FileTranspileResult;
1699

1700
            const expectedPkgPath = file.pkgPath.toLowerCase();
320✔
1701
            const expectedMapPath = `${expectedPkgPath}.map`;
320✔
1702
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
320✔
1703

1704
            //add a temporary plugin to tap into the file writing process
1705
            const plugin = this.plugins.addFirst({
320✔
1706
                name: 'getTranspiledFileContents',
1707
                beforeWriteFile: (event) => {
1708
                    const pkgPath = event.file.pkgPath.toLowerCase();
998✔
1709
                    switch (pkgPath) {
998✔
1710
                        //this is the actual transpiled file
1711
                        case expectedPkgPath:
998✔
1712
                            result.code = event.file.data.toString();
320✔
1713
                            break;
320✔
1714
                        //this is the sourcemap
1715
                        case expectedMapPath:
1716
                            result.map = event.file.data.toString();
171✔
1717
                            break;
171✔
1718
                        //this is the typedef
1719
                        case expectedTypedefPkgPath:
1720
                            result.typedef = event.file.data.toString();
8✔
1721
                            break;
8✔
1722
                        default:
1723
                        //no idea what this file is. just ignore it
1724
                    }
1725
                    //mark every file as processed so it they don't get written to the output directory
1726
                    event.processedFiles.add(event.file);
998✔
1727
                }
1728
            });
1729

1730
            try {
320✔
1731
                //now that the plugin has been registered, run the build with just this file
1732
                await this.build({
320✔
1733
                    files: [file]
1734
                });
1735
            } finally {
1736
                this.plugins.remove(plugin);
320✔
1737
            }
1738
            return result;
320✔
1739
        });
1740
    }
1741
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,945✔
1742

1743
    /**
1744
     * Get the absolute output path for a file
1745
     */
1746
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1747
        return s`${stagingDir}/${file.pkgPath}`;
1,841✔
1748
    }
1749

1750
    private getStagingDir(stagingDir?: string) {
1751
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
721✔
1752
        if (!result) {
721✔
1753
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
533✔
1754
        }
1755
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
721!
1756
        return result;
721✔
1757
    }
1758

1759
    /**
1760
     * Prepare the program for building
1761
     * @param files the list of files that should be prepared
1762
     */
1763
    private async prepare(files: BscFile[]) {
1764
        const programEvent: PrepareProgramEvent = {
361✔
1765
            program: this,
1766
            editor: this.editor,
1767
            files: files
1768
        };
1769

1770
        //assign an editor to every file
1771
        for (const file of programEvent.files) {
361✔
1772
            //if the file doesn't have an editor yet, assign one now
1773
            if (!file.editor) {
732✔
1774
                file.editor = new Editor();
685✔
1775
            }
1776
        }
1777

1778
        //sort the entries to make transpiling more deterministic
1779
        programEvent.files.sort((a, b) => {
361✔
1780
            if (a.pkgPath < b.pkgPath) {
386✔
1781
                return -1;
324✔
1782
            } else if (a.pkgPath > b.pkgPath) {
62!
1783
                return 1;
62✔
1784
            } else {
UNCOV
1785
                return 1;
×
1786
            }
1787
        });
1788

1789
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
361✔
1790
        await this.plugins.emitAsync('prepareProgram', programEvent);
361✔
1791

1792
        const stagingDir = this.getStagingDir();
361✔
1793

1794
        const entries: TranspileObj[] = [];
361✔
1795

1796
        for (const file of files) {
361✔
1797
            const scope = this.getFirstScopeForFile(file);
732✔
1798
            //link the symbol table for all the files in this scope
1799
            scope?.linkSymbolTable();
732✔
1800

1801
            //if the file doesn't have an editor yet, assign one now
1802
            if (!file.editor) {
732!
UNCOV
1803
                file.editor = new Editor();
×
1804
            }
1805
            const event = {
732✔
1806
                program: this,
1807
                file: file,
1808
                editor: file.editor,
1809
                scope: scope,
1810
                outputPath: this.getOutputPath(file, stagingDir)
1811
            } as PrepareFileEvent & { outputPath: string };
1812

1813
            await this.plugins.emitAsync('beforePrepareFile', event);
732✔
1814
            await this.plugins.emitAsync('prepareFile', event);
732✔
1815
            await this.plugins.emitAsync('afterPrepareFile', event);
732✔
1816

1817
            //TODO remove this in v1
1818
            entries.push(event);
732✔
1819

1820
            //unlink the symbolTable so the next loop iteration can link theirs
1821
            scope?.unlinkSymbolTable();
732✔
1822
        }
1823

1824
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
361✔
1825
        return files;
361✔
1826
    }
1827

1828
    /**
1829
     * Generate the contents of every file
1830
     */
1831
    private async serialize(files: BscFile[]) {
1832

1833
        const allFiles = new Map<BscFile, SerializedFile[]>();
360✔
1834

1835
        //exclude prunable files if that option is enabled
1836
        if (this.options.pruneEmptyCodeFiles === true) {
360✔
1837
            files = files.filter(x => x.canBePruned !== true);
9✔
1838
        }
1839

1840
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
360✔
1841
            program: this,
1842
            files: files,
1843
            result: allFiles
1844
        });
1845
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
360✔
1846

1847
        // serialize each file
1848
        for (const file of files) {
360✔
1849
            let scope = this.getFirstScopeForFile(file);
729✔
1850

1851
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1852
            if (!scope) {
729✔
1853
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
371✔
1854
                scope.getAllFiles = () => [file];
3,326✔
1855
                scope.getOwnFiles = scope.getAllFiles;
371✔
1856
            }
1857

1858
            //link the symbol table for all the files in this scope
1859
            scope?.linkSymbolTable();
729!
1860
            const event: SerializeFileEvent = {
729✔
1861
                program: this,
1862
                file: file,
1863
                scope: scope,
1864
                result: allFiles
1865
            };
1866
            await this.plugins.emitAsync('beforeSerializeFile', event);
729✔
1867
            await this.plugins.emitAsync('serializeFile', event);
729✔
1868
            await this.plugins.emitAsync('afterSerializeFile', event);
729✔
1869
            //unlink the symbolTable so the next loop iteration can link theirs
1870
            scope?.unlinkSymbolTable();
729!
1871
        }
1872

1873
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
360✔
1874

1875
        return allFiles;
360✔
1876
    }
1877

1878
    /**
1879
     * Write the entire project to disk
1880
     */
1881
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1882
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
360✔
1883
            program: this,
1884
            files: files,
1885
            stagingDir: stagingDir
1886
        });
1887
        //empty the staging directory
1888
        await fsExtra.emptyDir(stagingDir);
360✔
1889

1890
        const serializedFiles = [...files]
360✔
1891
            .map(([, serializedFiles]) => serializedFiles)
729✔
1892
            .flat();
1893

1894
        //write all the files to disk (asynchronously)
1895
        await Promise.all(
360✔
1896
            serializedFiles.map(async (file) => {
1897
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,109✔
1898
                    program: this,
1899
                    file: file,
1900
                    outputPath: this.getOutputPath(file, stagingDir),
1901
                    processedFiles: new Set<SerializedFile>()
1902
                });
1903

1904
                await this.plugins.emitAsync('writeFile', event);
1,109✔
1905

1906
                await this.plugins.emitAsync('afterWriteFile', event);
1,109✔
1907
            })
1908
        );
1909

1910
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
360✔
1911
    }
1912

1913
    private buildPipeline = new ActionPipeline();
1,945✔
1914

1915
    /**
1916
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1917
     * @param options the list of options used to build the program
1918
     */
1919
    public async build(options?: ProgramBuildOptions) {
1920
        //run a single build at a time
1921
        await this.buildPipeline.run(async () => {
360✔
1922
            const stagingDir = this.getStagingDir(options?.stagingDir);
360✔
1923

1924
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
360✔
1925
                program: this,
1926
                editor: this.editor,
1927
                files: options?.files ?? Object.values(this.files)
2,160✔
1928
            });
1929

1930
            //prepare the program (and files) for building
1931
            event.files = await this.prepare(event.files);
360✔
1932

1933
            //stage the entire program
1934
            const serializedFilesByFile = await this.serialize(event.files);
360✔
1935

1936
            await this.write(stagingDir, serializedFilesByFile);
360✔
1937

1938
            await this.plugins.emitAsync('afterBuildProgram', event);
360✔
1939

1940
            //undo all edits for the program
1941
            this.editor.undoAll();
360✔
1942
            //undo all edits for each file
1943
            for (const file of event.files) {
360✔
1944
                file.editor.undoAll();
730✔
1945
            }
1946
        });
1947

1948
        this.logger.debug('Types Created', TypesCreated);
360✔
1949
        let totalTypesCreated = 0;
360✔
1950
        for (const key in TypesCreated) {
360✔
1951
            if (TypesCreated.hasOwnProperty(key)) {
9,352!
1952
                totalTypesCreated += TypesCreated[key];
9,352✔
1953

1954
            }
1955
        }
1956
        this.logger.info('Total Types Created', totalTypesCreated);
360✔
1957
    }
1958

1959
    /**
1960
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1961
     */
1962
    public findFilesForFunction(functionName: string) {
1963
        const files = [] as BscFile[];
7✔
1964
        const lowerFunctionName = functionName.toLowerCase();
7✔
1965
        //find every file with this function defined
1966
        for (const file of Object.values(this.files)) {
7✔
1967
            if (isBrsFile(file)) {
25✔
1968
                //TODO handle namespace-relative function calls
1969
                //if the file has a function with this name
1970
                // eslint-disable-next-line @typescript-eslint/dot-notation
1971
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
1972
                    files.push(file);
2✔
1973
                }
1974
            }
1975
        }
1976
        return files;
7✔
1977
    }
1978

1979
    /**
1980
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1981
     */
1982
    public findFilesForClass(className: string) {
1983
        const files = [] as BscFile[];
7✔
1984
        const lowerClassName = className.toLowerCase();
7✔
1985
        //find every file with this class defined
1986
        for (const file of Object.values(this.files)) {
7✔
1987
            if (isBrsFile(file)) {
25✔
1988
                //TODO handle namespace-relative classes
1989
                //if the file has a function with this name
1990

1991
                // eslint-disable-next-line @typescript-eslint/dot-notation
1992
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
1993
                    files.push(file);
1✔
1994
                }
1995
            }
1996
        }
1997
        return files;
7✔
1998
    }
1999

2000
    public findFilesForNamespace(name: string) {
2001
        const files = [] as BscFile[];
7✔
2002
        const lowerName = name.toLowerCase();
7✔
2003
        //find every file with this class defined
2004
        for (const file of Object.values(this.files)) {
7✔
2005
            if (isBrsFile(file)) {
25✔
2006

2007
                // eslint-disable-next-line @typescript-eslint/dot-notation
2008
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
2009
                    const namespaceName = x.name.toLowerCase();
7✔
2010
                    return (
7✔
2011
                        //the namespace name matches exactly
2012
                        namespaceName === lowerName ||
9✔
2013
                        //the full namespace starts with the name (honoring the part boundary)
2014
                        namespaceName.startsWith(lowerName + '.')
2015
                    );
2016
                })) {
2017
                    files.push(file);
6✔
2018
                }
2019
            }
2020
        }
2021

2022
        return files;
7✔
2023
    }
2024

2025
    public findFilesForEnum(name: string) {
2026
        const files = [] as BscFile[];
8✔
2027
        const lowerName = name.toLowerCase();
8✔
2028
        //find every file with this enum defined
2029
        for (const file of Object.values(this.files)) {
8✔
2030
            if (isBrsFile(file)) {
26✔
2031
                // eslint-disable-next-line @typescript-eslint/dot-notation
2032
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
2033
                    files.push(file);
1✔
2034
                }
2035
            }
2036
        }
2037
        return files;
8✔
2038
    }
2039

2040
    private _manifest: Map<string, string>;
2041

2042
    /**
2043
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
2044
     * @param parsedManifest The manifest map to read from and modify
2045
     */
2046
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
2047
        // Lift the bs_consts defined in the manifest
2048
        let bsConsts = getBsConst(parsedManifest, false);
17✔
2049

2050
        // Override or delete any bs_consts defined in the bs config
2051
        for (const key in this.options?.manifest?.bs_const) {
17!
2052
            const value = this.options.manifest.bs_const[key];
3✔
2053
            if (value === null) {
3✔
2054
                bsConsts.delete(key);
1✔
2055
            } else {
2056
                bsConsts.set(key, value);
2✔
2057
            }
2058
        }
2059

2060
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
2061
        let constString = '';
17✔
2062
        for (const [key, value] of bsConsts) {
17✔
2063
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
2064
        }
2065

2066
        // Set the updated bs_const value
2067
        parsedManifest.set('bs_const', constString);
17✔
2068
    }
2069

2070
    /**
2071
     * Try to find and load the manifest into memory
2072
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
2073
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
2074
     */
2075
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,580✔
2076
        //if we already have a manifest instance, and should not replace...then don't replace
2077
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,588!
UNCOV
2078
            return;
×
2079
        }
2080
        let manifestPath = manifestFileObj
1,588✔
2081
            ? manifestFileObj.src
1,588✔
2082
            : path.join(this.options.rootDir, 'manifest');
2083

2084
        try {
1,588✔
2085
            // we only load this manifest once, so do it sync to improve speed downstream
2086
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,588✔
2087
            const parsedManifest = parseManifest(contents);
17✔
2088
            this.buildBsConstsIntoParsedManifest(parsedManifest);
17✔
2089
            this._manifest = parsedManifest;
17✔
2090
        } catch (e) {
2091
            this._manifest = new Map();
1,571✔
2092
        }
2093
    }
2094

2095
    /**
2096
     * Get a map of the manifest information
2097
     */
2098
    public getManifest() {
2099
        if (!this._manifest) {
2,535✔
2100
            this.loadManifest();
1,579✔
2101
        }
2102
        return this._manifest;
2,535✔
2103
    }
2104

2105
    public dispose() {
2106
        this.plugins.emit('beforeProgramDispose', { program: this });
1,820✔
2107

2108
        for (let filePath in this.files) {
1,820✔
2109
            this.files[filePath]?.dispose?.();
2,309!
2110
        }
2111
        for (let name in this.scopes) {
1,820✔
2112
            this.scopes[name]?.dispose?.();
3,834!
2113
        }
2114
        this.globalScope?.dispose?.();
1,820!
2115
        this.dependencyGraph?.dispose?.();
1,820!
2116
    }
2117
}
2118

2119
export interface FileTranspileResult {
2120
    srcPath: string;
2121
    destPath: string;
2122
    pkgPath: string;
2123
    code: string;
2124
    map: string;
2125
    typedef: string;
2126
}
2127

2128

2129
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2130
    constructor(
2131
        public program: Program,
2,619✔
2132
        public srcPath: string,
2,619✔
2133
        public destPath: string,
2,619✔
2134
        public data: LazyFileData,
2,619✔
2135
        public fileFactory: FileFactory
2,619✔
2136
    ) {
2137
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,619!
2138
    }
2139

2140
    public srcExtension: string;
2141

2142
    public files: TFile[] = [];
2,619✔
2143
}
2144

2145
export interface ProgramBuildOptions {
2146
    /**
2147
     * The directory where the final built files should be placed. This directory will be cleared before running
2148
     */
2149
    stagingDir?: string;
2150
    /**
2151
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2152
     * Typically you will want to leave this blank
2153
     */
2154
    files?: BscFile[];
2155
}
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