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

rokucommunity / brighterscript / #13664

28 Jan 2025 02:09PM UTC coverage: 86.757%. Remained the same
#13664

push

web-flow
Merge 62bbd6b91 into aa9aba86f

12553 of 15295 branches covered (82.07%)

Branch coverage included in aggregate %.

94 of 100 new or added lines in 13 files covered. (94.0%)

108 existing lines in 10 files now uncovered.

13423 of 14646 relevant lines covered (91.65%)

34160.83 hits per line

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

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

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

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

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

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

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

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

96
        this.createGlobalScope();
1,881✔
97

98
        this.fileFactory = new FileFactory(this);
1,881✔
99
    }
100

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

104
    /**
105
     * 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`)
106
     */
107
    public editor = new Editor();
1,881✔
108

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

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

120
        this.populateGlobalSymbolTable();
1,881✔
121
        this.globalScope.symbolTable.addSibling(this.componentsTable);
1,881✔
122

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

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

131
        // Get declarations for all annotations from all plugins
132
        this.populateAnnotationSymbolTable();
1,881✔
133
    }
134

135

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

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

172
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
1,881✔
173

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

185
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
839,301✔
186

187
        for (const callable of globalCallables) {
1,881✔
188
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
146,718✔
189
        }
190

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

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

208
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,881✔
209
            this.recursivelyAddNodeToSymbolTable(nodeData);
180,576✔
210
        }
211

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

219
    }
220

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

229
    public diagnostics: DiagnosticManager;
230

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

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

242
    public pluginAnnotationTable = new SymbolTable('Plugin Annotations', () => this.globalScope?.symbolTable);
1,881!
243

244
    private populateAnnotationSymbolTable() {
245
        for (const [pluginName, annotations] of this.plugins.getAnnotationMap().entries()) {
1,881✔
246
            for (const annotation of annotations) {
1✔
247
                if (isTypedFunctionType(annotation) && annotation.name) {
1!
NEW
248
                    this.addAnnotationSymbol(annotation.name, annotation, { pluginName: pluginName });
×
249
                } else if (isAnnotationDeclaration(annotation)) {
1!
250
                    const annoType = annotation.type;
1✔
251
                    let description = (typeof annotation.description === 'string') ? annotation.description : undefined;
1!
252
                    this.addAnnotationSymbol(annoType.name, annoType, { pluginName: pluginName, description: description });
1✔
NEW
253
                } else if (typeof annotation === 'string') {
×
254
                    // TODO: Do we need to parse this?
255
                }
256
            }
257
        }
258
    }
259

260
    public addAnnotationSymbol(name: string, annoType: TypedFunctionType, extraData: ExtraSymbolData = {}) {
22✔
261
        if (name && annoType) {
24!
262
            annoType.setName(name);
24✔
263
            const pluginName = extraData?.pluginName ?? '';
24!
264
            this.logger.info(`Adding annotation '${name}' (${pluginName})`);
24✔
265
            this.pluginAnnotationTable.addSymbol(name, extraData, annoType, SymbolTypeFlag.annotation);
24✔
266
        }
267
    }
268

269
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
1,881✔
270

271
    private currentScopeValidationOptions: ScopeValidationOptions;
272

273
    /**
274
     *  Map of typetime symbols which depend upon the key symbol
275
     */
276
    private symbolDependencies = new Map<string, Set<string>>();
1,881✔
277

278

279
    /**
280
     * Symbol Table for storing custom component types
281
     * This is a sibling to the global table (as Components can be used/referenced anywhere)
282
     * Keeping custom components out of the global table and in a specific symbol table
283
     * compartmentalizes their use
284
     */
285
    private componentsTable = new SymbolTable('Custom Components');
1,881✔
286

287
    public addFileSymbolInfo(file: BrsFile) {
288
        this.fileSymbolInformation.set(file.pkgPath, {
1,764✔
289
            provides: file.providedSymbols,
290
            requires: file.requiredSymbols
291
        });
292
    }
293

294
    public getFileSymbolInfo(file: BrsFile) {
295
        return this.fileSymbolInformation.get(file.pkgPath);
1,768✔
296
    }
297

298
    /**
299
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
300
     */
301
    public get bslibPkgPath() {
302
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
303
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
2,473✔
304
            return bslibAliasedRokuModulesPkgPath;
11✔
305

306
            //if there's a non-aliased version of bslib from roku_modules, use that
307
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
2,462✔
308
            return bslibNonAliasedRokuModulesPkgPath;
24✔
309

310
            //default to the embedded version
311
        } else {
312
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
2,438✔
313
        }
314
    }
315

316
    public get bslibPrefix() {
317
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
1,802✔
318
            return 'rokucommunity_bslib';
18✔
319
        } else {
320
            return 'bslib';
1,784✔
321
        }
322
    }
323

324

325
    /**
326
     * A map of every file loaded into this program, indexed by its original file location
327
     */
328
    public files = {} as Record<string, BscFile>;
1,881✔
329
    /**
330
     * A map of every file loaded into this program, indexed by its destPath
331
     */
332
    private destMap = new Map<string, BscFile>();
1,881✔
333
    /**
334
     * Plugins can contribute multiple virtual files for a single physical file.
335
     * This collection links the virtual files back to the physical file that produced them.
336
     * The key is the standardized and lower-cased srcPath
337
     */
338
    private fileClusters = new Map<string, BscFile[]>();
1,881✔
339

340
    private scopes = {} as Record<string, Scope>;
1,881✔
341

342
    protected addScope(scope: Scope) {
343
        this.scopes[scope.name] = scope;
2,060✔
344
        delete this.sortedScopeNames;
2,060✔
345
    }
346

347
    protected removeScope(scope: Scope) {
348
        if (this.scopes[scope.name]) {
16!
349
            delete this.scopes[scope.name];
16✔
350
            delete this.sortedScopeNames;
16✔
351
        }
352
    }
353

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

362
    /**
363
     * Get the component with the specified name
364
     */
365
    public getComponent(componentName: string) {
366
        if (componentName) {
2,874✔
367
            //return the first compoment in the list with this name
368
            //(components are ordered in this list by destPath to ensure consistency)
369
            return this.components[componentName.toLowerCase()]?.[0];
2,860✔
370
        } else {
371
            return undefined;
14✔
372
        }
373
    }
374

375
    /**
376
     * Get the sorted names of custom components
377
     */
378
    public getSortedComponentNames() {
379
        const componentNames = Object.keys(this.components);
1,426✔
380
        componentNames.sort((a, b) => {
1,426✔
381
            if (a < b) {
715✔
382
                return -1;
287✔
383
            } else if (b < a) {
428!
384
                return 1;
428✔
385
            }
UNCOV
386
            return 0;
×
387
        });
388
        return componentNames;
1,426✔
389
    }
390

391
    /**
392
     * Keeps a set of all the components that need to have their types updated during the current validation cycle
393
     * Map <componentKey, componentName>
394
     */
395
    private componentSymbolsToUpdate = new Map<string, string>();
1,881✔
396

397
    /**
398
     * Register (or replace) the reference to a component in the component map
399
     */
400
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
401
        const key = this.getComponentKey(xmlFile);
423✔
402
        if (!this.components[key]) {
423✔
403
            this.components[key] = [];
406✔
404
        }
405
        this.components[key].push({
423✔
406
            file: xmlFile,
407
            scope: scope
408
        });
409
        this.components[key].sort((a, b) => {
423✔
410
            const pathA = a.file.destPath.toLowerCase();
5✔
411
            const pathB = b.file.destPath.toLowerCase();
5✔
412
            if (pathA < pathB) {
5✔
413
                return -1;
1✔
414
            } else if (pathA > pathB) {
4!
415
                return 1;
4✔
416
            }
417
            return 0;
×
418
        });
419
        this.syncComponentDependencyGraph(this.components[key]);
423✔
420
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
423✔
421
    }
422

423
    /**
424
     * Remove the specified component from the components map
425
     */
426
    private unregisterComponent(xmlFile: XmlFile) {
427
        const key = this.getComponentKey(xmlFile);
16✔
428
        const arr = this.components[key] || [];
16!
429
        for (let i = 0; i < arr.length; i++) {
16✔
430
            if (arr[i].file === xmlFile) {
16!
431
                arr.splice(i, 1);
16✔
432
                break;
16✔
433
            }
434
        }
435

436
        this.syncComponentDependencyGraph(arr);
16✔
437
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
16✔
438
    }
439

440
    /**
441
     * Adds a component described in an XML to the set of components that needs to be updated this validation cycle.
442
     * @param xmlFile XML file with <component> tag
443
     */
444
    private addDeferredComponentTypeSymbolCreation(xmlFile: XmlFile) {
445
        const componentKey = this.getComponentKey(xmlFile);
1,400✔
446
        const componentName = xmlFile.componentName?.text;
1,400✔
447
        if (this.componentSymbolsToUpdate.has(componentKey)) {
1,400✔
448
            return;
852✔
449
        }
450
        this.componentSymbolsToUpdate.set(componentKey, componentName);
548✔
451
    }
452

453
    private getComponentKey(xmlFile: XmlFile) {
454
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,839✔
455
    }
456

457
    /**
458
     * Resolves symbol table with the first component in this.components to have the same name as the component in the file
459
     * @param componentKey key getting a component from `this.components`
460
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
461
     */
462
    private updateComponentSymbolInGlobalScope(componentKey: string, componentName: string) {
463
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
481✔
464
        if (!symbolName) {
481✔
465
            return;
7✔
466
        }
467
        const components = this.components[componentKey] || [];
474!
468
        const previousComponentType = this.componentsTable.getSymbolType(symbolName, { flags: SymbolTypeFlag.typetime });
474✔
469
        // Remove any existing symbols that match
470
        this.componentsTable.removeSymbol(symbolName);
474✔
471
        if (components.length > 0) {
474✔
472
            // There is a component that can be added - use it.
473
            const componentScope = components[0].scope;
473✔
474

475
            this.componentsTable.removeSymbol(symbolName);
473✔
476
            componentScope.linkSymbolTable();
473✔
477
            const componentType = componentScope.getComponentType();
473✔
478
            if (componentType) {
473!
479
                this.componentsTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
473✔
480
            }
481
            const typeData = {};
473✔
482
            const isSameAsPrevious = previousComponentType && componentType.isEqual(previousComponentType, typeData);
473✔
483
            const isComponentTypeDifferent = !previousComponentType || isReferenceType(previousComponentType) || !isSameAsPrevious;
473✔
484
            componentScope.unlinkSymbolTable();
473✔
485
            return isComponentTypeDifferent;
473✔
486

487
        }
488
        // There was a previous component type, but no new one, so it's different
489
        return !!previousComponentType;
1✔
490
    }
491

492
    /**
493
     * 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
494
     * This is so on a first validation, these types can be resolved in teh future (eg. when the actual component is created)
495
     * If we don't add reference types at this top level, they will be created at the file level, and will never get resolved
496
     * @param componentKey key getting a component from `this.components`
497
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
498
     */
499
    private addComponentReferenceType(componentKey: string, componentName: string) {
500
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
481✔
501
        if (!symbolName) {
481✔
502
            return;
7✔
503
        }
504
        const components = this.components[componentKey] || [];
474!
505

506
        if (components.length > 0) {
474✔
507
            // There is a component that can be added,
508
            if (!this.componentsTable.hasSymbol(symbolName, SymbolTypeFlag.typetime)) {
473✔
509
                // it doesn't already exist in the table
510
                const componentRefType = new ReferenceType(symbolName, symbolName, SymbolTypeFlag.typetime, () => this.componentsTable);
3,217✔
511
                if (componentRefType) {
333!
512
                    this.componentsTable.addSymbol(symbolName, {}, componentRefType, SymbolTypeFlag.typetime);
333✔
513
                }
514
            }
515
        } else {
516
            // there is no component. remove from table
517
            this.componentsTable.removeSymbol(symbolName);
1✔
518
        }
519
    }
520

521
    /**
522
     * re-attach the dependency graph with a new key for any component who changed
523
     * their position in their own named array (only matters when there are multiple
524
     * components with the same name)
525
     */
526
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
527
        //reattach every dependency graph
528
        for (let i = 0; i < components.length; i++) {
439✔
529
            const { file, scope } = components[i];
429✔
530

531
            //attach (or re-attach) the dependencyGraph for every component whose position changed
532
            if (file.dependencyGraphIndex !== i) {
429✔
533
                file.dependencyGraphIndex = i;
425✔
534
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
425✔
535
                file.attachDependencyGraph(this.dependencyGraph);
425✔
536
                scope.attachDependencyGraph(this.dependencyGraph);
425✔
537
            }
538
        }
539
    }
540

541
    /**
542
     * Get a list of all files that are included in the project but are not referenced
543
     * by any scope in the program.
544
     */
545
    public getUnreferencedFiles() {
UNCOV
546
        let result = [] as BscFile[];
×
UNCOV
547
        for (let filePath in this.files) {
×
UNCOV
548
            let file = this.files[filePath];
×
549
            //is this file part of a scope
UNCOV
550
            if (!this.getFirstScopeForFile(file)) {
×
551
                //no scopes reference this file. add it to the list
UNCOV
552
                result.push(file);
×
553
            }
554
        }
UNCOV
555
        return result;
×
556
    }
557

558
    /**
559
     * Get the list of errors for the entire program.
560
     */
561
    public getDiagnostics() {
562
        return this.diagnostics.getDiagnostics();
1,222✔
563
    }
564

565
    /**
566
     * Determine if the specified file is loaded in this program right now.
567
     * @param filePath the absolute or relative path to the file
568
     * @param normalizePath should the provided path be normalized before use
569
     */
570
    public hasFile(filePath: string, normalizePath = true) {
2,687✔
571
        return !!this.getFile(filePath, normalizePath);
2,687✔
572
    }
573

574
    /**
575
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
576
     * @param scopeName xml scope names are their `destPath`. Source scope is stored with the key `"source"`
577
     */
578
    public getScopeByName(scopeName: string): Scope | undefined {
579
        if (!scopeName) {
61!
UNCOV
580
            return undefined;
×
581
        }
582
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
583
        //so it's safe to run the standardizePkgPath method
584
        scopeName = s`${scopeName}`;
61✔
585
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
139✔
586
        return this.scopes[key!];
61✔
587
    }
588

589
    /**
590
     * Return all scopes
591
     */
592
    public getScopes() {
593
        return Object.values(this.scopes);
13✔
594
    }
595

596
    /**
597
     * Find the scope for the specified component
598
     */
599
    public getComponentScope(componentName: string) {
600
        return this.getComponent(componentName)?.scope;
896✔
601
    }
602

603
    /**
604
     * Update internal maps with this file reference
605
     */
606
    private assignFile<T extends BscFile = BscFile>(file: T) {
607
        const fileAddEvent: BeforeFileAddEvent = {
2,499✔
608
            file: file,
609
            program: this
610
        };
611

612
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,499✔
613

614
        this.files[file.srcPath.toLowerCase()] = file;
2,499✔
615
        this.destMap.set(file.destPath.toLowerCase(), file);
2,499✔
616

617
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,499✔
618

619
        return file;
2,499✔
620
    }
621

622
    /**
623
     * Remove this file from internal maps
624
     */
625
    private unassignFile<T extends BscFile = BscFile>(file: T) {
626
        delete this.files[file.srcPath.toLowerCase()];
161✔
627
        this.destMap.delete(file.destPath.toLowerCase());
161✔
628
        return file;
161✔
629
    }
630

631
    /**
632
     * Load a file into the program. If that file already exists, it is replaced.
633
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
634
     * @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:/`)
635
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
636
     */
637
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileData?: FileData): T;
638
    /**
639
     * Load a file into the program. If that file already exists, it is replaced.
640
     * @param fileEntry an object that specifies src and dest for the file.
641
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
642
     */
643
    public setFile<T extends BscFile>(fileEntry: FileObj, fileData: FileData): T;
644
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileData: FileData): T {
645
        //normalize the file paths
646
        const { srcPath, destPath } = this.getPaths(fileParam, this.options.rootDir);
2,495✔
647

648
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
2,495✔
649
            //if the file is already loaded, remove it
650
            if (this.hasFile(srcPath)) {
2,495✔
651
                this.removeFile(srcPath, true, true);
145✔
652
            }
653

654
            const data = new LazyFileData(fileData);
2,495✔
655

656
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,495✔
657

658
            this.plugins.emit('beforeProvideFile', event);
2,495✔
659
            this.plugins.emit('provideFile', event);
2,495✔
660
            this.plugins.emit('afterProvideFile', event);
2,495✔
661

662
            //if no files were provided, create a AssetFile to represent it.
663
            if (event.files.length === 0) {
2,495✔
664
                event.files.push(
18✔
665
                    this.fileFactory.AssetFile({
666
                        srcPath: event.srcPath,
667
                        destPath: event.destPath,
668
                        pkgPath: event.destPath,
669
                        data: data
670
                    })
671
                );
672
            }
673

674
            //find the file instance for the srcPath that triggered this action.
675
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
2,495✔
676

677
            if (!primaryFile) {
2,495!
UNCOV
678
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
679
                    type: x.type,
680
                    srcPath: x.srcPath,
681
                    destPath: x.destPath
682
                })))}`);
683
            }
684

685
            //link the virtual files to the primary file
686
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
2,495!
687

688
            for (const file of event.files) {
2,495✔
689
                file.srcPath = s(file.srcPath);
2,499✔
690
                if (file.destPath) {
2,499!
691
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
2,499✔
692
                }
693
                if (file.pkgPath) {
2,499✔
694
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
2,495✔
695
                } else {
696
                    file.pkgPath = file.destPath;
4✔
697
                }
698
                file.excludeFromOutput = file.excludeFromOutput === true;
2,499✔
699

700
                //set the dependencyGraph key for every file to its destPath
701
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,499✔
702

703
                this.assignFile(file);
2,499✔
704

705
                //register a callback anytime this file's dependencies change
706
                if (typeof file.onDependenciesChanged === 'function') {
2,499✔
707
                    file.disposables ??= [];
2,473!
708
                    file.disposables.push(
2,473✔
709
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
710
                    );
711
                }
712

713
                //register this file (and its dependencies) with the dependency graph
714
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
2,499✔
715

716
                //if this is a `source` file, add it to the source scope's dependency list
717
                if (this.isSourceBrsFile(file)) {
2,499✔
718
                    this.createSourceScope();
1,682✔
719
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
1,682✔
720
                }
721

722
                //if this is an xml file in the components folder, register it as a component
723
                if (this.isComponentsXmlFile(file)) {
2,499✔
724
                    //create a new scope for this xml file
725
                    let scope = new XmlScope(file, this);
423✔
726
                    this.addScope(scope);
423✔
727

728
                    //register this componet now that we have parsed it and know its component name
729
                    this.registerComponent(file, scope);
423✔
730

731
                    //notify plugins that the scope is created and the component is registered
732
                    this.plugins.emit('afterScopeCreate', {
423✔
733
                        program: this,
734
                        scope: scope
735
                    });
736
                }
737
            }
738

739
            return primaryFile;
2,495✔
740
        });
741
        return file as T;
2,495✔
742
    }
743

744
    /**
745
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
746
     * @param fileParam an object representing file paths
747
     * @param rootDir must be a pre-normalized path
748
     */
749
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
750
        let srcPath: string | undefined;
751
        let destPath: string | undefined;
752

753
        assert.ok(fileParam, 'fileParam is required');
2,659✔
754

755
        //lift the path vars from the incoming param
756
        if (typeof fileParam === 'string') {
2,659✔
757
            fileParam = this.removePkgPrefix(fileParam);
2,296✔
758
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,296✔
759
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,296✔
760
        } else {
761
            let param: any = fileParam;
363✔
762

763
            if (param.src) {
363✔
764
                srcPath = s`${param.src}`;
362✔
765
            }
766
            if (param.srcPath) {
363!
UNCOV
767
                srcPath = s`${param.srcPath}`;
×
768
            }
769
            if (param.dest) {
363✔
770
                destPath = s`${this.removePkgPrefix(param.dest)}`;
362✔
771
            }
772
            if (param.pkgPath) {
363!
UNCOV
773
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
774
            }
775
        }
776

777
        //if there's no srcPath, use the destPath to build an absolute srcPath
778
        if (!srcPath) {
2,659✔
779
            srcPath = s`${rootDir}/${destPath}`;
1✔
780
        }
781
        //coerce srcPath to an absolute path
782
        if (!path.isAbsolute(srcPath)) {
2,659✔
783
            srcPath = util.standardizePath(srcPath);
1✔
784
        }
785

786
        //if destPath isn't set, compute it from the other paths
787
        if (!destPath) {
2,659✔
788
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
789
        }
790

791
        assert.ok(srcPath, 'fileEntry.src is required');
2,659✔
792
        assert.ok(destPath, 'fileEntry.dest is required');
2,659✔
793

794
        return {
2,659✔
795
            srcPath: srcPath,
796
            //remove leading slash
797
            destPath: destPath.replace(/^[\/\\]+/, '')
798
        };
799
    }
800

801
    /**
802
     * Remove any leading `pkg:/` found in the path
803
     */
804
    private removePkgPrefix(path: string) {
805
        return path.replace(/^pkg:\//i, '');
2,658✔
806
    }
807

808
    /**
809
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
810
     */
811
    private isSourceBrsFile(file: BscFile) {
812
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
2,660✔
813
    }
814

815
    /**
816
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
817
     */
818
    private isComponentsXmlFile(file: BscFile): file is XmlFile {
819
        return isXmlFile(file) && !!/^(pkg:\/)?components[\/\\]/.exec(file.destPath);
2,499✔
820
    }
821

822
    /**
823
     * Ensure source scope is created.
824
     * Note: automatically called internally, and no-op if it exists already.
825
     */
826
    public createSourceScope() {
827
        if (!this.scopes.source) {
2,486✔
828
            const sourceScope = new Scope('source', this, 'scope:source');
1,637✔
829
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,637✔
830
            this.addScope(sourceScope);
1,637✔
831
            this.plugins.emit('afterScopeCreate', {
1,637✔
832
                program: this,
833
                scope: sourceScope
834
            });
835
        }
836
    }
837

838
    /**
839
     * Remove a set of files from the program
840
     * @param srcPaths can be an array of srcPath or destPath strings
841
     * @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
842
     */
843
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
844
        for (let srcPath of srcPaths) {
1✔
845
            this.removeFile(srcPath, normalizePath);
1✔
846
        }
847
    }
848

849
    /**
850
     * Remove a file from the program
851
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
852
     * @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
853
     */
854
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
27✔
855
        this.logger.debug('Program.removeFile()', filePath);
159✔
856
        const paths = this.getPaths(filePath, this.options.rootDir);
159✔
857

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

861
        for (const file of files) {
159✔
862
            //if a file has already been removed, nothing more needs to be done here
863
            if (!file || !this.hasFile(file.srcPath)) {
162✔
864
                continue;
1✔
865
            }
866
            this.diagnostics.clearForFile(file.srcPath);
161✔
867

868
            const event: BeforeFileRemoveEvent = { file: file, program: this };
161✔
869
            this.plugins.emit('beforeFileRemove', event);
161✔
870

871
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
872
            let scope = this.scopes[file.destPath];
161✔
873
            if (scope) {
161✔
874
                this.logger.debug('Removing associated scope', scope.name);
16✔
875
                const scopeDisposeEvent = {
16✔
876
                    program: this,
877
                    scope: scope
878
                };
879
                this.plugins.emit('beforeScopeDispose', scopeDisposeEvent);
16✔
880
                this.plugins.emit('onScopeDispose', scopeDisposeEvent);
16✔
881
                scope.dispose();
16✔
882
                //notify dependencies of this scope that it has been removed
883
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
16✔
884
                this.removeScope(this.scopes[file.destPath]);
16✔
885
                this.plugins.emit('afterScopeDispose', scopeDisposeEvent);
16✔
886
            }
887
            //remove the file from the program
888
            this.unassignFile(file);
161✔
889

890
            this.dependencyGraph.remove(file.dependencyGraphKey);
161✔
891

892
            //if this is a pkg:/source file, notify the `source` scope that it has changed
893
            if (this.isSourceBrsFile(file)) {
161✔
894
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
127✔
895
            }
896
            if (isBrsFile(file)) {
161✔
897
                this.logger.debug('Removing file symbol info', file.srcPath);
139✔
898

899
                if (!keepSymbolInformation) {
139✔
900
                    this.fileSymbolInformation.delete(file.pkgPath);
8✔
901
                }
902
                this.crossScopeValidation.clearResolutionsForFile(file);
139✔
903
            }
904

905
            //if this is a component, remove it from our components map
906
            if (isXmlFile(file)) {
161✔
907
                this.logger.debug('Unregistering component', file.srcPath);
16✔
908

909
                this.unregisterComponent(file);
16✔
910
            }
911
            this.logger.debug('Disposing file', file.srcPath);
161✔
912

913
            //dispose any disposable things on the file
914
            for (const disposable of file?.disposables ?? []) {
161!
915
                disposable();
155✔
916
            }
917
            //dispose file
918
            file?.dispose?.();
161!
919

920
            this.plugins.emit('afterFileRemove', event);
161✔
921
        }
922
    }
923

924
    public crossScopeValidation = new CrossScopeValidator(this);
1,881✔
925

926
    private isFirstValidation = true;
1,881✔
927

928
    /**
929
     * Traverse the entire project, and validate all scopes
930
     */
931
    public validate() {
932
        this.logger.time(LogLevel.log, ['Validating project'], () => {
1,426✔
933
            this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,426✔
934
            const programValidateEvent = {
1,426✔
935
                program: this
936
            };
937
            this.plugins.emit('beforeProgramValidate', programValidateEvent);
1,426✔
938
            this.plugins.emit('onProgramValidate', programValidateEvent);
1,426✔
939

940
            const metrics = {
1,426✔
941
                filesChanged: 0,
942
                filesValidated: 0,
943
                fileValidationTime: '',
944
                crossScopeValidationTime: '',
945
                scopesValidated: 0,
946
                changedSymbols: 0,
947
                totalLinkTime: '',
948
                totalScopeValidationTime: '',
949
                componentValidationTime: '',
950
                changedSymbolsTime: ''
951
            };
952

953
            const validationStopwatch = new Stopwatch();
1,426✔
954
            //validate every file
955
            const brsFilesValidated: BrsFile[] = [];
1,426✔
956
            const xmlFilesValidated: XmlFile[] = [];
1,426✔
957

958
            const afterValidateFiles: BscFile[] = [];
1,426✔
959
            const sortedFiles = Object.values(this.files).sort(firstBy(x => x.srcPath));
3,804✔
960
            this.logger.time(LogLevel.info, ['Prebuild component types'], () => {
1,426✔
961
                // cast a wide net for potential changes in components
962
                for (const file of sortedFiles) {
1,426✔
963
                    if (file.isValidated) {
2,410✔
964
                        continue;
353✔
965
                    }
966
                    if (isXmlFile(file)) {
2,057✔
967
                        this.addDeferredComponentTypeSymbolCreation(file);
350✔
968
                    } else if (isBrsFile(file)) {
1,707!
969
                        for (const scope of this.getScopesForFile(file)) {
1,707✔
970
                            if (isXmlScope(scope)) {
2,001✔
971
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
611✔
972
                            }
973
                        }
974
                    }
975
                }
976

977
                // Create reference component types for any component that changes
978
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,426✔
979
                    this.addComponentReferenceType(componentKey, componentName);
481✔
980
                }
981
            });
982

983

984
            metrics.fileValidationTime = validationStopwatch.getDurationTextFor(() => {
1,426✔
985
                //sort files by path so we get consistent results
986
                for (const file of sortedFiles) {
1,426✔
987
                    //for every unvalidated file, validate it
988
                    if (!file.isValidated) {
2,410✔
989
                        const validateFileEvent = {
2,057✔
990
                            program: this,
991
                            file: file
992
                        };
993
                        this.plugins.emit('beforeFileValidate', validateFileEvent);
2,057✔
994
                        //emit an event to allow plugins to contribute to the file validation process
995
                        this.plugins.emit('onFileValidate', validateFileEvent);
2,057✔
996
                        file.isValidated = true;
2,057✔
997
                        if (isBrsFile(file)) {
2,057✔
998
                            brsFilesValidated.push(file);
1,707✔
999
                        } else if (isXmlFile(file)) {
350!
1000
                            xmlFilesValidated.push(file);
350✔
1001
                        }
1002
                        afterValidateFiles.push(file);
2,057✔
1003
                    }
1004
                }
1005
                // AfterFileValidate is after all files have been validated
1006
                for (const file of afterValidateFiles) {
1,426✔
1007
                    const validateFileEvent = {
2,057✔
1008
                        program: this,
1009
                        file: file
1010
                    };
1011
                    this.plugins.emit('afterFileValidate', validateFileEvent);
2,057✔
1012
                }
1013
            }).durationText;
1014

1015
            metrics.filesChanged = afterValidateFiles.length;
1,426✔
1016

1017
            const changedComponentTypes: string[] = [];
1,426✔
1018

1019
            // Build component types for any component that changes
1020
            this.logger.time(LogLevel.info, ['Build component types'], () => {
1,426✔
1021
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,426✔
1022
                    if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
481✔
1023
                        changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
341✔
1024
                    }
1025
                }
1026
                this.componentSymbolsToUpdate.clear();
1,426✔
1027
            });
1028

1029
            // get set of changed symbols
1030
            const changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,426✔
1031
            metrics.changedSymbolsTime = validationStopwatch.getDurationTextFor(() => {
1,426✔
1032

1033
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,426!
1034
                    if (isBrsFile(f)) {
2,057✔
1035
                        return f.providedSymbols.changes;
1,707✔
1036
                    }
1037
                    return null;
350✔
1038
                }).filter(x => x);
2,057✔
1039

1040
                // update the map of typetime dependencies
1041
                for (const file of brsFilesValidated) {
1,426✔
1042
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,707✔
1043
                        // clear existing dependencies
1044
                        for (const values of this.symbolDependencies.values()) {
659✔
1045
                            values.delete(symbolName);
62✔
1046
                        }
1047

1048
                        // map types to the set of types that depend upon them
1049
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
659!
1050
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
182✔
1051
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
182✔
1052
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
160✔
1053
                            }
1054
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
182✔
1055
                            symbolsDependentUpon.add(symbolName);
182✔
1056
                        }
1057
                    }
1058
                }
1059

1060
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,426✔
1061
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,414✔
1062
                    const changedSymbolSet = new Set<string>();
2,852✔
1063
                    for (const changeSet of changedSymbolsSetArr) {
2,852✔
1064
                        for (const change of changeSet) {
3,414✔
1065
                            changedSymbolSet.add(change);
3,480✔
1066
                        }
1067
                    }
1068
                    changedSymbols.set(flag, changedSymbolSet);
2,852✔
1069
                }
1070

1071
                // update changed symbol set with any changed component
1072
                for (const changedComponentType of changedComponentTypes) {
1,426✔
1073
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
341✔
1074
                }
1075

1076
                // Add any additional types that depend on a changed type
1077
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1078
                const dependentTypesChanged = new Set<string>();
1,426✔
1079
                let foundDependentTypes = false;
1,426✔
1080
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,426✔
1081
                do {
1,426✔
1082
                    foundDependentTypes = false;
1,432✔
1083
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
1,432✔
1084
                    for (const changedSymbol of allChangedTypesSofar) {
1,432✔
1085
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,004✔
1086
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,004✔
1087
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
186✔
1088
                                foundDependentTypes = true;
6✔
1089
                                dependentTypesChanged.add(symbolName);
6✔
1090
                            }
1091
                        }
1092
                    }
1093
                } while (foundDependentTypes);
1094

1095
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedTypeSymbols, ...dependentTypesChanged]));
1,426✔
1096
            }).durationText;
1097

1098
            if (this.options.logLevel === LogLevel.debug) {
1,426!
UNCOV
1099
                const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
UNCOV
1100
                this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
UNCOV
1101
                const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
UNCOV
1102
                this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1103
            }
1104
            metrics.changedSymbols = changedSymbols.get(SymbolTypeFlag.runtime).size + changedSymbols.get(SymbolTypeFlag.typetime).size;
1,426✔
1105
            const filesToBeValidatedInScopeContext = new Set<BscFile>(afterValidateFiles);
1,426✔
1106

1107
            metrics.crossScopeValidationTime = validationStopwatch.getDurationTextFor(() => {
1,426✔
1108
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,426✔
1109
                this.crossScopeValidation.buildComponentsMap();
1,426✔
1110
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,426✔
1111
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,426✔
1112
                for (const file of filesToRevalidate) {
1,426✔
1113
                    filesToBeValidatedInScopeContext.add(file);
416✔
1114
                }
1115
            }).durationText;
1116

1117
            metrics.filesValidated = filesToBeValidatedInScopeContext.size;
1,426✔
1118

1119
            let linkTime = 0;
1,426✔
1120
            let validationTime = 0;
1,426✔
1121
            let scopesValidated = 0;
1,426✔
1122
            let changedFiles = new Set<BscFile>(afterValidateFiles);
1,426✔
1123
            this.currentScopeValidationOptions = {
1,426✔
1124
                filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1125
                changedSymbols: changedSymbols,
1126
                changedFiles: changedFiles,
1127
                initialValidation: this.isFirstValidation
1128
            };
1129
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
1,426✔
1130
                //sort the scope names so we get consistent results
1131
                const scopeNames = this.getSortedScopeNames();
1,426✔
1132
                for (const file of filesToBeValidatedInScopeContext) {
1,426✔
1133
                    if (isBrsFile(file)) {
2,186✔
1134
                        file.validationSegmenter.unValidateAllSegments();
1,836✔
1135
                        for (const scope of this.getScopesForFile(file)) {
1,836✔
1136
                            scope.invalidate();
2,132✔
1137
                        }
1138
                    }
1139
                }
1140
                for (let scopeName of scopeNames) {
1,426✔
1141
                    let scope = this.scopes[scopeName];
3,255✔
1142
                    const scopeValidated = scope.validate(this.currentScopeValidationOptions);
3,255✔
1143
                    if (scopeValidated) {
3,255✔
1144
                        scopesValidated++;
1,770✔
1145
                    }
1146
                    linkTime += scope.validationMetrics.linkTime;
3,255✔
1147
                    validationTime += scope.validationMetrics.validationTime;
3,255✔
1148
                }
1149
            });
1150
            metrics.scopesValidated = scopesValidated;
1,426✔
1151
            validationStopwatch.totalMilliseconds = linkTime;
1,426✔
1152
            metrics.totalLinkTime = validationStopwatch.getDurationText();
1,426✔
1153

1154
            validationStopwatch.totalMilliseconds = validationTime;
1,426✔
1155
            metrics.totalScopeValidationTime = validationStopwatch.getDurationText();
1,426✔
1156

1157
            metrics.componentValidationTime = validationStopwatch.getDurationTextFor(() => {
1,426✔
1158
                this.detectDuplicateComponentNames();
1,426✔
1159
            }).durationText;
1160

1161
            this.logValidationMetrics(metrics);
1,426✔
1162

1163
            this.isFirstValidation = false;
1,426✔
1164

1165
            this.plugins.emit('afterProgramValidate', programValidateEvent);
1,426✔
1166
        });
1167
    }
1168

1169
    // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
1170
    private logValidationMetrics(metrics: { [key: string]: number | string }) {
1171
        let logs = [] as string[];
1,426✔
1172
        for (const key in metrics) {
1,426✔
1173
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
14,260✔
1174
        }
1175
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
1,426✔
1176
    }
1177

1178
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1179
        const scopesForCrossScopeValidation = [];
1,426✔
1180
        for (let scopeName of this.getSortedScopeNames()) {
1,426✔
1181
            let scope = this.scopes[scopeName];
3,255✔
1182
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,255✔
1183
                scopesForCrossScopeValidation.push(scope);
1,803✔
1184
            }
1185
        }
1186
        return scopesForCrossScopeValidation;
1,426✔
1187
    }
1188

1189
    /**
1190
     * Flag all duplicate component names
1191
     */
1192
    private detectDuplicateComponentNames() {
1193
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,426✔
1194
            const file = this.files[filePath];
2,410✔
1195
            //if this is an XmlFile, and it has a valid `componentName` property
1196
            if (isXmlFile(file) && file.componentName?.text) {
2,410✔
1197
                let lowerName = file.componentName.text.toLowerCase();
491✔
1198
                if (!map[lowerName]) {
491✔
1199
                    map[lowerName] = [];
488✔
1200
                }
1201
                map[lowerName].push(file);
491✔
1202
            }
1203
            return map;
2,410✔
1204
        }, {});
1205

1206
        for (let name in componentsByName) {
1,426✔
1207
            const xmlFiles = componentsByName[name];
488✔
1208
            //add diagnostics for every duplicate component with this name
1209
            if (xmlFiles.length > 1) {
488✔
1210
                for (let xmlFile of xmlFiles) {
3✔
1211
                    const { componentName } = xmlFile;
6✔
1212
                    this.diagnostics.register({
6✔
1213
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1214
                        location: xmlFile.componentName.location,
1215
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1216
                            return {
6✔
1217
                                location: x.componentName.location,
1218
                                message: 'Also defined here'
1219
                            };
1220
                        })
1221
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1222
                }
1223
            }
1224
        }
1225
    }
1226

1227
    /**
1228
     * Get the files for a list of filePaths
1229
     * @param filePaths can be an array of srcPath or a destPath strings
1230
     * @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
1231
     */
1232
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1233
        return filePaths
29✔
1234
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1235
            .filter(file => file !== undefined) as T[];
39✔
1236
    }
1237

1238
    /**
1239
     * Get the file at the given path
1240
     * @param filePath can be a srcPath or a destPath
1241
     * @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
1242
     */
1243
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
18,765✔
1244
        if (typeof filePath !== 'string') {
25,405✔
1245
            return undefined;
3,555✔
1246
            //is the path absolute (or the `virtual:` prefix)
1247
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
21,850✔
1248
            return this.files[
4,757✔
1249
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
4,757!
1250
            ] as T;
1251
        } else if (util.isUriLike(filePath)) {
17,093✔
1252
            const path = URI.parse(filePath).fsPath;
1,378✔
1253
            return this.files[
1,378✔
1254
                (normalizePath ? util.standardizePath(path) : path).toLowerCase()
1,378!
1255
            ] as T;
1256
        } else {
1257
            return this.destMap.get(
15,715✔
1258
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
15,715✔
1259
            ) as T;
1260
        }
1261
    }
1262

1263
    private sortedScopeNames: string[] = undefined;
1,881✔
1264

1265
    /**
1266
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1267
     */
1268
    private getSortedScopeNames() {
1269
        if (!this.sortedScopeNames) {
11,297✔
1270
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,377✔
1271
                if (a === 'global') {
1,984!
UNCOV
1272
                    return -1;
×
1273
                } else if (b === 'global') {
1,984✔
1274
                    return 1;
1,361✔
1275
                }
1276
                if (a === 'source') {
623✔
1277
                    return -1;
28✔
1278
                } else if (b === 'source') {
595✔
1279
                    return 1;
149✔
1280
                }
1281
                if (a < b) {
446✔
1282
                    return -1;
193✔
1283
                } else if (b < a) {
253!
1284
                    return 1;
253✔
1285
                }
UNCOV
1286
                return 0;
×
1287
            });
1288
        }
1289
        return this.sortedScopeNames;
11,297✔
1290
    }
1291

1292
    /**
1293
     * Get a list of all scopes the file is loaded into
1294
     * @param file the file
1295
     */
1296
    public getScopesForFile(file: BscFile | string) {
1297
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,313✔
1298

1299
        let result = [] as Scope[];
4,313✔
1300
        if (resolvedFile) {
4,313✔
1301
            const scopeKeys = this.getSortedScopeNames();
4,312✔
1302
            for (let key of scopeKeys) {
4,312✔
1303
                let scope = this.scopes[key];
39,992✔
1304

1305
                if (scope.hasFile(resolvedFile)) {
39,992✔
1306
                    result.push(scope);
4,916✔
1307
                }
1308
            }
1309
        }
1310
        return result;
4,313✔
1311
    }
1312

1313
    /**
1314
     * Get the first found scope for a file.
1315
     */
1316
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1317
        const scopeKeys = this.getSortedScopeNames();
4,133✔
1318
        for (let key of scopeKeys) {
4,133✔
1319
            let scope = this.scopes[key];
18,677✔
1320

1321
            if (scope.hasFile(file)) {
18,677✔
1322
                return scope;
3,019✔
1323
            }
1324
        }
1325
    }
1326

1327
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1328
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1329
        const filesSearched = new Set<BrsFile>();
39✔
1330
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1331
        let lowerName = name?.toLowerCase();
39!
1332

1333
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1334
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1335
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1336
                if (!results.has(statement)) {
36!
1337
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1338
                }
1339
            }
1340
        }
1341

1342
        //look through all files in scope for matches
1343
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1344
            for (const file of scope.getAllFiles()) {
39✔
1345
                //skip non-brs files, or files we've already processed
1346
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1347
                    continue;
3✔
1348
                }
1349
                filesSearched.add(file);
42✔
1350

1351
                file.ast.walk(createVisitor({
42✔
1352
                    FunctionStatement: (statement: FunctionStatement) => {
1353
                        addToResults(statement, file);
95✔
1354
                    },
1355
                    MethodStatement: (statement: MethodStatement) => {
1356
                        addToResults(statement, file);
3✔
1357
                    }
1358
                }), {
1359
                    walkMode: WalkMode.visitStatements
1360
                });
1361
            }
1362
        }
1363
        return [...results.values()];
39✔
1364
    }
1365

1366
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1367
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1368
        const filesSearched = new Set<BrsFile>();
14✔
1369

1370
        //get all function names for the xml file and parents
1371
        let funcNames = new Set<string>();
14✔
1372
        let currentScope = scope;
14✔
1373
        while (isXmlScope(currentScope)) {
14✔
1374
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1375
                if (!filterName || name === filterName) {
20!
1376
                    funcNames.add(name);
20✔
1377
                }
1378
            }
1379
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1380
        }
1381

1382
        //look through all files in scope for matches
1383
        for (const file of scope.getOwnFiles()) {
14✔
1384
            //skip non-brs files, or files we've already processed
1385
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1386
                continue;
14✔
1387
            }
1388
            filesSearched.add(file);
14✔
1389

1390
            file.ast.walk(createVisitor({
14✔
1391
                FunctionStatement: (statement: FunctionStatement) => {
1392
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1393
                        if (!results.has(statement)) {
19!
1394
                            results.set(statement, { item: statement, file: file });
19✔
1395
                        }
1396
                    }
1397
                }
1398
            }), {
1399
                walkMode: WalkMode.visitStatements
1400
            });
1401
        }
1402
        return [...results.values()];
14✔
1403
    }
1404

1405
    /**
1406
     * Find all available completion items at the given position
1407
     * @param filePath can be a srcPath or a destPath
1408
     * @param position the position (line & column) where completions should be found
1409
     */
1410
    public getCompletions(filePath: string, position: Position) {
1411
        let file = this.getFile(filePath);
120✔
1412
        if (!file) {
120!
UNCOV
1413
            return [];
×
1414
        }
1415

1416
        //find the scopes for this file
1417
        let scopes = this.getScopesForFile(file);
120✔
1418

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

1422
        const event: ProvideCompletionsEvent = {
120✔
1423
            program: this,
1424
            file: file,
1425
            scopes: scopes,
1426
            position: position,
1427
            completions: []
1428
        };
1429

1430
        this.plugins.emit('beforeProvideCompletions', event);
120✔
1431

1432
        this.plugins.emit('provideCompletions', event);
120✔
1433

1434
        this.plugins.emit('afterProvideCompletions', event);
120✔
1435

1436
        return event.completions;
120✔
1437
    }
1438

1439
    /**
1440
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1441
     */
1442
    public getWorkspaceSymbols() {
1443
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1444
            program: this,
1445
            workspaceSymbols: []
1446
        };
1447
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1448
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1449
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1450
        return event.workspaceSymbols;
22✔
1451
    }
1452

1453
    /**
1454
     * Given a position in a file, if the position is sitting on some type of identifier,
1455
     * go to the definition of that identifier (where this thing was first defined)
1456
     */
1457
    public getDefinition(srcPath: string, position: Position): Location[] {
1458
        let file = this.getFile(srcPath);
18✔
1459
        if (!file) {
18!
UNCOV
1460
            return [];
×
1461
        }
1462

1463
        const event: ProvideDefinitionEvent = {
18✔
1464
            program: this,
1465
            file: file,
1466
            position: position,
1467
            definitions: []
1468
        };
1469

1470
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1471
        this.plugins.emit('provideDefinition', event);
18✔
1472
        this.plugins.emit('afterProvideDefinition', event);
18✔
1473
        return event.definitions;
18✔
1474
    }
1475

1476
    /**
1477
     * Get hover information for a file and position
1478
     */
1479
    public getHover(srcPath: string, position: Position): Hover[] {
1480
        let file = this.getFile(srcPath);
69✔
1481
        let result: Hover[];
1482
        if (file) {
69!
1483
            const event = {
69✔
1484
                program: this,
1485
                file: file,
1486
                position: position,
1487
                scopes: this.getScopesForFile(file),
1488
                hovers: []
1489
            } as ProvideHoverEvent;
1490
            this.plugins.emit('beforeProvideHover', event);
69✔
1491
            this.plugins.emit('provideHover', event);
69✔
1492
            this.plugins.emit('afterProvideHover', event);
69✔
1493
            result = event.hovers;
69✔
1494
        }
1495

1496
        return result ?? [];
69!
1497
    }
1498

1499
    /**
1500
     * Get full list of document symbols for a file
1501
     * @param srcPath path to the file
1502
     */
1503
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1504
        let file = this.getFile(srcPath);
18✔
1505
        if (file) {
18!
1506
            const event: ProvideDocumentSymbolsEvent = {
18✔
1507
                program: this,
1508
                file: file,
1509
                documentSymbols: []
1510
            };
1511
            this.plugins.emit('beforeProvideDocumentSymbols', event);
18✔
1512
            this.plugins.emit('provideDocumentSymbols', event);
18✔
1513
            this.plugins.emit('afterProvideDocumentSymbols', event);
18✔
1514
            return event.documentSymbols;
18✔
1515
        } else {
UNCOV
1516
            return undefined;
×
1517
        }
1518
    }
1519

1520
    /**
1521
     * Compute code actions for the given file and range
1522
     */
1523
    public getCodeActions(srcPath: string, range: Range) {
1524
        const codeActions = [] as CodeAction[];
13✔
1525
        const file = this.getFile(srcPath);
13✔
1526
        if (file) {
13✔
1527
            const fileUri = util.pathToUri(file?.srcPath);
12!
1528
            const diagnostics = this
12✔
1529
                //get all current diagnostics (filtered by diagnostic filters)
1530
                .getDiagnostics()
1531
                //only keep diagnostics related to this file
1532
                .filter(x => x.location?.uri === fileUri)
22✔
1533
                //only keep diagnostics that touch this range
1534
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
12✔
1535

1536
            const scopes = this.getScopesForFile(file);
12✔
1537

1538
            this.plugins.emit('onGetCodeActions', {
12✔
1539
                program: this,
1540
                file: file,
1541
                range: range,
1542
                diagnostics: diagnostics,
1543
                scopes: scopes,
1544
                codeActions: codeActions
1545
            });
1546
        }
1547
        return codeActions;
13✔
1548
    }
1549

1550
    /**
1551
     * Get semantic tokens for the specified file
1552
     */
1553
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1554
        const file = this.getFile(srcPath);
24✔
1555
        if (file) {
24!
1556
            const result = [] as SemanticToken[];
24✔
1557
            this.plugins.emit('onGetSemanticTokens', {
24✔
1558
                program: this,
1559
                file: file,
1560
                scopes: this.getScopesForFile(file),
1561
                semanticTokens: result
1562
            });
1563
            return result;
24✔
1564
        }
1565
    }
1566

1567
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1568
        let file: BrsFile = this.getFile(filepath);
185✔
1569
        if (!file || !isBrsFile(file)) {
185✔
1570
            return [];
3✔
1571
        }
1572
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1573
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1574
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1575
    }
1576

1577
    public getReferences(srcPath: string, position: Position): Location[] {
1578
        //find the file
1579
        let file = this.getFile(srcPath);
4✔
1580

1581
        const event: ProvideReferencesEvent = {
4✔
1582
            program: this,
1583
            file: file,
1584
            position: position,
1585
            references: []
1586
        };
1587

1588
        this.plugins.emit('beforeProvideReferences', event);
4✔
1589
        this.plugins.emit('provideReferences', event);
4✔
1590
        this.plugins.emit('afterProvideReferences', event);
4✔
1591

1592
        return event.references;
4✔
1593
    }
1594

1595
    /**
1596
     * Transpile a single file and get the result as a string.
1597
     * This does not write anything to the file system.
1598
     *
1599
     * This should only be called by `LanguageServer`.
1600
     * Internal usage should call `_getTranspiledFileContents` instead.
1601
     * @param filePath can be a srcPath or a destPath
1602
     */
1603
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1604
        const file = this.getFile(filePath);
319✔
1605

1606
        return this.getTranspiledFileContentsPipeline.run(async () => {
319✔
1607

1608
            const result = {
319✔
1609
                destPath: file.destPath,
1610
                pkgPath: file.pkgPath,
1611
                srcPath: file.srcPath
1612
            } as FileTranspileResult;
1613

1614
            const expectedPkgPath = file.pkgPath.toLowerCase();
319✔
1615
            const expectedMapPath = `${expectedPkgPath}.map`;
319✔
1616
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
319✔
1617

1618
            //add a temporary plugin to tap into the file writing process
1619
            const plugin = this.plugins.addFirst({
319✔
1620
                name: 'getTranspiledFileContents',
1621
                beforeWriteFile: (event) => {
1622
                    const pkgPath = event.file.pkgPath.toLowerCase();
994✔
1623
                    switch (pkgPath) {
994✔
1624
                        //this is the actual transpiled file
1625
                        case expectedPkgPath:
994✔
1626
                            result.code = event.file.data.toString();
319✔
1627
                            break;
319✔
1628
                        //this is the sourcemap
1629
                        case expectedMapPath:
1630
                            result.map = event.file.data.toString();
170✔
1631
                            break;
170✔
1632
                        //this is the typedef
1633
                        case expectedTypedefPkgPath:
1634
                            result.typedef = event.file.data.toString();
8✔
1635
                            break;
8✔
1636
                        default:
1637
                        //no idea what this file is. just ignore it
1638
                    }
1639
                    //mark every file as processed so it they don't get written to the output directory
1640
                    event.processedFiles.add(event.file);
994✔
1641
                }
1642
            });
1643

1644
            try {
319✔
1645
                //now that the plugin has been registered, run the build with just this file
1646
                await this.build({
319✔
1647
                    files: [file]
1648
                });
1649
            } finally {
1650
                this.plugins.remove(plugin);
319✔
1651
            }
1652
            return result;
319✔
1653
        });
1654
    }
1655
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,881✔
1656

1657
    /**
1658
     * Get the absolute output path for a file
1659
     */
1660
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1661
        return s`${stagingDir}/${file.pkgPath}`;
1,835✔
1662
    }
1663

1664
    private getStagingDir(stagingDir?: string) {
1665
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
719✔
1666
        if (!result) {
719✔
1667
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
531✔
1668
        }
1669
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
719!
1670
        return result;
719✔
1671
    }
1672

1673
    /**
1674
     * Prepare the program for building
1675
     * @param files the list of files that should be prepared
1676
     */
1677
    private async prepare(files: BscFile[]) {
1678
        const programEvent: PrepareProgramEvent = {
360✔
1679
            program: this,
1680
            editor: this.editor,
1681
            files: files
1682
        };
1683

1684
        //assign an editor to every file
1685
        for (const file of programEvent.files) {
360✔
1686
            //if the file doesn't have an editor yet, assign one now
1687
            if (!file.editor) {
730✔
1688
                file.editor = new Editor();
683✔
1689
            }
1690
        }
1691

1692
        //sort the entries to make transpiling more deterministic
1693
        programEvent.files.sort((a, b) => {
360✔
1694
            if (a.pkgPath < b.pkgPath) {
386✔
1695
                return -1;
325✔
1696
            } else if (a.pkgPath > b.pkgPath) {
61!
1697
                return 1;
61✔
1698
            } else {
UNCOV
1699
                return 1;
×
1700
            }
1701
        });
1702

1703
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
360✔
1704
        await this.plugins.emitAsync('prepareProgram', programEvent);
360✔
1705

1706
        const stagingDir = this.getStagingDir();
360✔
1707

1708
        const entries: TranspileObj[] = [];
360✔
1709

1710
        for (const file of files) {
360✔
1711
            const scope = this.getFirstScopeForFile(file);
730✔
1712
            //link the symbol table for all the files in this scope
1713
            scope?.linkSymbolTable();
730✔
1714

1715
            //if the file doesn't have an editor yet, assign one now
1716
            if (!file.editor) {
730!
UNCOV
1717
                file.editor = new Editor();
×
1718
            }
1719
            const event = {
730✔
1720
                program: this,
1721
                file: file,
1722
                editor: file.editor,
1723
                scope: scope,
1724
                outputPath: this.getOutputPath(file, stagingDir)
1725
            } as PrepareFileEvent & { outputPath: string };
1726

1727
            await this.plugins.emitAsync('beforePrepareFile', event);
730✔
1728
            await this.plugins.emitAsync('prepareFile', event);
730✔
1729
            await this.plugins.emitAsync('afterPrepareFile', event);
730✔
1730

1731
            //TODO remove this in v1
1732
            entries.push(event);
730✔
1733

1734
            //unlink the symbolTable so the next loop iteration can link theirs
1735
            scope?.unlinkSymbolTable();
730✔
1736
        }
1737

1738
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
360✔
1739
        return files;
360✔
1740
    }
1741

1742
    /**
1743
     * Generate the contents of every file
1744
     */
1745
    private async serialize(files: BscFile[]) {
1746

1747
        const allFiles = new Map<BscFile, SerializedFile[]>();
359✔
1748

1749
        //exclude prunable files if that option is enabled
1750
        if (this.options.pruneEmptyCodeFiles === true) {
359✔
1751
            files = files.filter(x => x.canBePruned !== true);
9✔
1752
        }
1753

1754
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
359✔
1755
            program: this,
1756
            files: files,
1757
            result: allFiles
1758
        });
1759
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
359✔
1760

1761
        // serialize each file
1762
        for (const file of files) {
359✔
1763
            let scope = this.getFirstScopeForFile(file);
727✔
1764

1765
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1766
            if (!scope) {
727✔
1767
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
370✔
1768
                scope.getAllFiles = () => [file];
3,317✔
1769
                scope.getOwnFiles = scope.getAllFiles;
370✔
1770
            }
1771

1772
            //link the symbol table for all the files in this scope
1773
            scope?.linkSymbolTable();
727!
1774
            const event: SerializeFileEvent = {
727✔
1775
                program: this,
1776
                file: file,
1777
                scope: scope,
1778
                result: allFiles
1779
            };
1780
            await this.plugins.emitAsync('beforeSerializeFile', event);
727✔
1781
            await this.plugins.emitAsync('serializeFile', event);
727✔
1782
            await this.plugins.emitAsync('afterSerializeFile', event);
727✔
1783
            //unlink the symbolTable so the next loop iteration can link theirs
1784
            scope?.unlinkSymbolTable();
727!
1785
        }
1786

1787
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
359✔
1788

1789
        return allFiles;
359✔
1790
    }
1791

1792
    /**
1793
     * Write the entire project to disk
1794
     */
1795
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1796
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
359✔
1797
            program: this,
1798
            files: files,
1799
            stagingDir: stagingDir
1800
        });
1801
        //empty the staging directory
1802
        await fsExtra.emptyDir(stagingDir);
359✔
1803

1804
        const serializedFiles = [...files]
359✔
1805
            .map(([, serializedFiles]) => serializedFiles)
727✔
1806
            .flat();
1807

1808
        //write all the files to disk (asynchronously)
1809
        await Promise.all(
359✔
1810
            serializedFiles.map(async (file) => {
1811
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,105✔
1812
                    program: this,
1813
                    file: file,
1814
                    outputPath: this.getOutputPath(file, stagingDir),
1815
                    processedFiles: new Set<SerializedFile>()
1816
                });
1817

1818
                await this.plugins.emitAsync('writeFile', event);
1,105✔
1819

1820
                await this.plugins.emitAsync('afterWriteFile', event);
1,105✔
1821
            })
1822
        );
1823

1824
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
359✔
1825
    }
1826

1827
    private buildPipeline = new ActionPipeline();
1,881✔
1828

1829
    /**
1830
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1831
     * @param options the list of options used to build the program
1832
     */
1833
    public async build(options?: ProgramBuildOptions) {
1834
        //run a single build at a time
1835
        await this.buildPipeline.run(async () => {
359✔
1836
            const stagingDir = this.getStagingDir(options?.stagingDir);
359✔
1837

1838
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
359✔
1839
                program: this,
1840
                editor: this.editor,
1841
                files: options?.files ?? Object.values(this.files)
2,154✔
1842
            });
1843

1844
            //prepare the program (and files) for building
1845
            event.files = await this.prepare(event.files);
359✔
1846

1847
            //stage the entire program
1848
            const serializedFilesByFile = await this.serialize(event.files);
359✔
1849

1850
            await this.write(stagingDir, serializedFilesByFile);
359✔
1851

1852
            await this.plugins.emitAsync('afterBuildProgram', event);
359✔
1853

1854
            //undo all edits for the program
1855
            this.editor.undoAll();
359✔
1856
            //undo all edits for each file
1857
            for (const file of event.files) {
359✔
1858
                file.editor.undoAll();
728✔
1859
            }
1860
        });
1861
    }
1862

1863
    /**
1864
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1865
     */
1866
    public findFilesForFunction(functionName: string) {
1867
        const files = [] as BscFile[];
7✔
1868
        const lowerFunctionName = functionName.toLowerCase();
7✔
1869
        //find every file with this function defined
1870
        for (const file of Object.values(this.files)) {
7✔
1871
            if (isBrsFile(file)) {
25✔
1872
                //TODO handle namespace-relative function calls
1873
                //if the file has a function with this name
1874
                // eslint-disable-next-line @typescript-eslint/dot-notation
1875
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
1876
                    files.push(file);
2✔
1877
                }
1878
            }
1879
        }
1880
        return files;
7✔
1881
    }
1882

1883
    /**
1884
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1885
     */
1886
    public findFilesForClass(className: string) {
1887
        const files = [] as BscFile[];
7✔
1888
        const lowerClassName = className.toLowerCase();
7✔
1889
        //find every file with this class defined
1890
        for (const file of Object.values(this.files)) {
7✔
1891
            if (isBrsFile(file)) {
25✔
1892
                //TODO handle namespace-relative classes
1893
                //if the file has a function with this name
1894

1895
                // eslint-disable-next-line @typescript-eslint/dot-notation
1896
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
1897
                    files.push(file);
1✔
1898
                }
1899
            }
1900
        }
1901
        return files;
7✔
1902
    }
1903

1904
    public findFilesForNamespace(name: string) {
1905
        const files = [] as BscFile[];
7✔
1906
        const lowerName = name.toLowerCase();
7✔
1907
        //find every file with this class defined
1908
        for (const file of Object.values(this.files)) {
7✔
1909
            if (isBrsFile(file)) {
25✔
1910

1911
                // eslint-disable-next-line @typescript-eslint/dot-notation
1912
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
1913
                    const namespaceName = x.name.toLowerCase();
7✔
1914
                    return (
7✔
1915
                        //the namespace name matches exactly
1916
                        namespaceName === lowerName ||
9✔
1917
                        //the full namespace starts with the name (honoring the part boundary)
1918
                        namespaceName.startsWith(lowerName + '.')
1919
                    );
1920
                })) {
1921
                    files.push(file);
6✔
1922
                }
1923
            }
1924
        }
1925

1926
        return files;
7✔
1927
    }
1928

1929
    public findFilesForEnum(name: string) {
1930
        const files = [] as BscFile[];
8✔
1931
        const lowerName = name.toLowerCase();
8✔
1932
        //find every file with this enum defined
1933
        for (const file of Object.values(this.files)) {
8✔
1934
            if (isBrsFile(file)) {
26✔
1935
                // eslint-disable-next-line @typescript-eslint/dot-notation
1936
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
1937
                    files.push(file);
1✔
1938
                }
1939
            }
1940
        }
1941
        return files;
8✔
1942
    }
1943

1944
    private _manifest: Map<string, string>;
1945

1946
    /**
1947
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1948
     * @param parsedManifest The manifest map to read from and modify
1949
     */
1950
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1951
        // Lift the bs_consts defined in the manifest
1952
        let bsConsts = getBsConst(parsedManifest, false);
15✔
1953

1954
        // Override or delete any bs_consts defined in the bs config
1955
        for (const key in this.options?.manifest?.bs_const) {
15!
1956
            const value = this.options.manifest.bs_const[key];
3✔
1957
            if (value === null) {
3✔
1958
                bsConsts.delete(key);
1✔
1959
            } else {
1960
                bsConsts.set(key, value);
2✔
1961
            }
1962
        }
1963

1964
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1965
        let constString = '';
15✔
1966
        for (const [key, value] of bsConsts) {
15✔
1967
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1968
        }
1969

1970
        // Set the updated bs_const value
1971
        parsedManifest.set('bs_const', constString);
15✔
1972
    }
1973

1974
    /**
1975
     * Try to find and load the manifest into memory
1976
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1977
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1978
     */
1979
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,551✔
1980
        //if we already have a manifest instance, and should not replace...then don't replace
1981
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,557!
UNCOV
1982
            return;
×
1983
        }
1984
        let manifestPath = manifestFileObj
1,557✔
1985
            ? manifestFileObj.src
1,557✔
1986
            : path.join(this.options.rootDir, 'manifest');
1987

1988
        try {
1,557✔
1989
            // we only load this manifest once, so do it sync to improve speed downstream
1990
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,557✔
1991
            const parsedManifest = parseManifest(contents);
15✔
1992
            this.buildBsConstsIntoParsedManifest(parsedManifest);
15✔
1993
            this._manifest = parsedManifest;
15✔
1994
        } catch (e) {
1995
            this._manifest = new Map();
1,542✔
1996
        }
1997
    }
1998

1999
    /**
2000
     * Get a map of the manifest information
2001
     */
2002
    public getManifest() {
2003
        if (!this._manifest) {
2,412✔
2004
            this.loadManifest();
1,550✔
2005
        }
2006
        return this._manifest;
2,412✔
2007
    }
2008

2009
    public dispose() {
2010
        this.plugins.emit('beforeProgramDispose', { program: this });
1,717✔
2011

2012
        for (let filePath in this.files) {
1,717✔
2013
            this.files[filePath]?.dispose?.();
2,153!
2014
        }
2015
        for (let name in this.scopes) {
1,717✔
2016
            this.scopes[name]?.dispose?.();
3,632!
2017
        }
2018
        this.globalScope?.dispose?.();
1,717!
2019
        this.dependencyGraph?.dispose?.();
1,717!
2020
    }
2021
}
2022

2023
export interface FileTranspileResult {
2024
    srcPath: string;
2025
    destPath: string;
2026
    pkgPath: string;
2027
    code: string;
2028
    map: string;
2029
    typedef: string;
2030
}
2031

2032

2033
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2034
    constructor(
2035
        public program: Program,
2,495✔
2036
        public srcPath: string,
2,495✔
2037
        public destPath: string,
2,495✔
2038
        public data: LazyFileData,
2,495✔
2039
        public fileFactory: FileFactory
2,495✔
2040
    ) {
2041
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,495!
2042
    }
2043

2044
    public srcExtension: string;
2045

2046
    public files: TFile[] = [];
2,495✔
2047
}
2048

2049
export interface ProgramBuildOptions {
2050
    /**
2051
     * The directory where the final built files should be placed. This directory will be cleared before running
2052
     */
2053
    stagingDir?: string;
2054
    /**
2055
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2056
     * Typically you will want to leave this blank
2057
     */
2058
    files?: BscFile[];
2059
}
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