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

rokucommunity / brighterscript / #13959

25 Feb 2025 04:52PM UTC coverage: 86.712%. Remained the same
#13959

push

web-flow
Merge 0efcb946d into 37997a86e

12642 of 15412 branches covered (82.03%)

Branch coverage included in aggregate %.

80 of 85 new or added lines in 18 files covered. (94.12%)

82 existing lines in 12 files now uncovered.

13545 of 14788 relevant lines covered (91.59%)

20033.81 hits per line

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

93.34
/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 } 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 { ReferenceType, TypesCreated } from './types';
1✔
59

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

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

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

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

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

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

95
        this.createGlobalScope();
1,883✔
96

97
        this.fileFactory = new FileFactory(this);
1,883✔
98
    }
99

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

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

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

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

119
        this.populateGlobalSymbolTable();
1,883✔
120
        this.globalScope.symbolTable.addSibling(this.componentsTable);
1,883✔
121

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

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

131

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

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

168
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
1,883✔
169

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

181
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
549,864✔
182

183
        for (const callable of globalCallables) {
1,883✔
184
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
146,874✔
185
        }
186

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

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

204
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,883✔
205
            this.recursivelyAddNodeToSymbolTable(nodeData);
180,768✔
206
        }
207

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

215
    }
216

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

225
    public diagnostics: DiagnosticManager;
226

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

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

238
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
1,883✔
239

240
    private currentScopeValidationOptions: ScopeValidationOptions;
241

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

247

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

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

263
    public getFileSymbolInfo(file: BrsFile) {
264
        return this.fileSymbolInformation.get(file.pkgPath);
1,765✔
265
    }
266

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

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

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

285
    public get bslibPrefix() {
286
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
1,802✔
287
            return 'rokucommunity_bslib';
18✔
288
        } else {
289
            return 'bslib';
1,784✔
290
        }
291
    }
292

293

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

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

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

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

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

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

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

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

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

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

405
        this.syncComponentDependencyGraph(arr);
16✔
406
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
16✔
407
    }
408

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

422
    private getComponentKey(xmlFile: XmlFile) {
423
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,839✔
424
    }
425

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

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

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

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

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

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

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

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

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

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

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

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

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

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

581
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,496✔
582

583
        this.files[file.srcPath.toLowerCase()] = file;
2,496✔
584
        this.destMap.set(file.destPath.toLowerCase(), file);
2,496✔
585

586
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,496✔
587

588
        return file;
2,496✔
589
    }
590

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

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

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

623
            const data = new LazyFileData(fileData);
2,492✔
624

625
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,492✔
626

627
            this.plugins.emit('beforeProvideFile', event);
2,492✔
628
            this.plugins.emit('provideFile', event);
2,492✔
629
            this.plugins.emit('afterProvideFile', event);
2,492✔
630

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

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

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

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

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

669
                //set the dependencyGraph key for every file to its destPath
670
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,496✔
671

672
                this.assignFile(file);
2,496✔
673

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

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

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

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

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

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

708
            return primaryFile;
2,492✔
709
        });
710
        return file as T;
2,492✔
711
    }
712

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

722
        assert.ok(fileParam, 'fileParam is required');
2,656✔
723

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

732
            if (param.src) {
366✔
733
                srcPath = s`${param.src}`;
365✔
734
            }
735
            if (param.srcPath) {
366!
736
                srcPath = s`${param.srcPath}`;
×
737
            }
738
            if (param.dest) {
366✔
739
                destPath = s`${this.removePkgPrefix(param.dest)}`;
365✔
740
            }
741
            if (param.pkgPath) {
366!
742
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
743
            }
744
        }
745

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

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

760
        assert.ok(srcPath, 'fileEntry.src is required');
2,656✔
761
        assert.ok(destPath, 'fileEntry.dest is required');
2,656✔
762

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

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

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

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

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

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

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

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

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

837
            const event: BeforeFileRemoveEvent = { file: file, program: this };
161✔
838
            this.plugins.emit('beforeFileRemove', event);
161✔
839

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

859
            this.dependencyGraph.remove(file.dependencyGraphKey);
161✔
860

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

868
                if (!keepSymbolInformation) {
139✔
869
                    this.fileSymbolInformation.delete(file.pkgPath);
8✔
870
                }
871
                this.crossScopeValidation.clearResolutionsForFile(file);
139✔
872
            }
873

874
            this.diagnostics.clearForFile(file.srcPath);
161✔
875

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

880
                this.unregisterComponent(file);
16✔
881
            }
882
            this.logger.debug('Disposing file', file.srcPath);
161✔
883

884
            //dispose any disposable things on the file
885
            for (const disposable of file?.disposables ?? []) {
161!
886
                disposable();
155✔
887
            }
888
            //dispose file
889
            file?.dispose?.();
161!
890

891
            this.plugins.emit('afterFileRemove', event);
161✔
892
        }
893
    }
894

895
    public crossScopeValidation = new CrossScopeValidator(this);
1,883✔
896

897
    private isFirstValidation = true;
1,883✔
898

899
    /**
900
     * Traverse the entire project, and validate all scopes
901
     */
902
    public validate() {
903
        this.logger.time(LogLevel.log, ['Validating project'], () => {
1,424✔
904
            this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,424✔
905
            const programValidateEvent = {
1,424✔
906
                program: this
907
            };
908
            this.plugins.emit('beforeProgramValidate', programValidateEvent);
1,424✔
909
            this.plugins.emit('onProgramValidate', programValidateEvent);
1,424✔
910

911
            const metrics = {
1,424✔
912
                filesChanged: 0,
913
                filesValidated: 0,
914
                fileValidationTime: '',
915
                crossScopeValidationTime: '',
916
                scopesValidated: 0,
917
                changedSymbols: 0,
918
                totalLinkTime: '',
919
                totalScopeValidationTime: '',
920
                componentValidationTime: '',
921
                changedSymbolsTime: ''
922
            };
923

924
            const validationStopwatch = new Stopwatch();
1,424✔
925
            //validate every file
926
            const brsFilesValidated: BrsFile[] = [];
1,424✔
927
            const xmlFilesValidated: XmlFile[] = [];
1,424✔
928

929
            const afterValidateFiles: BscFile[] = [];
1,424✔
930
            const sortedFiles = Object.values(this.files).sort(firstBy(x => x.srcPath));
3,800✔
931
            this.logger.time(LogLevel.info, ['Prebuild component types'], () => {
1,424✔
932
                // cast a wide net for potential changes in components
933
                for (const file of sortedFiles) {
1,424✔
934
                    if (file.isValidated) {
2,406✔
935
                        continue;
352✔
936
                    }
937
                    if (isXmlFile(file)) {
2,054✔
938
                        this.addDeferredComponentTypeSymbolCreation(file);
350✔
939
                    } else if (isBrsFile(file)) {
1,704!
940
                        for (const scope of this.getScopesForFile(file)) {
1,704✔
941
                            if (isXmlScope(scope)) {
1,998✔
942
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
611✔
943
                            }
944
                        }
945
                    }
946
                }
947

948
                // Create reference component types for any component that changes
949
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,424✔
950
                    this.addComponentReferenceType(componentKey, componentName);
481✔
951
                }
952
            });
953

954

955
            metrics.fileValidationTime = validationStopwatch.getDurationTextFor(() => {
1,424✔
956
                //sort files by path so we get consistent results
957
                for (const file of sortedFiles) {
1,424✔
958
                    //for every unvalidated file, validate it
959
                    if (!file.isValidated) {
2,406✔
960
                        const validateFileEvent = {
2,054✔
961
                            program: this,
962
                            file: file
963
                        };
964
                        this.plugins.emit('beforeFileValidate', validateFileEvent);
2,054✔
965
                        //emit an event to allow plugins to contribute to the file validation process
966
                        this.plugins.emit('onFileValidate', validateFileEvent);
2,054✔
967
                        file.isValidated = true;
2,054✔
968
                        if (isBrsFile(file)) {
2,054✔
969
                            brsFilesValidated.push(file);
1,704✔
970
                        } else if (isXmlFile(file)) {
350!
971
                            xmlFilesValidated.push(file);
350✔
972
                        }
973
                        afterValidateFiles.push(file);
2,054✔
974
                    }
975
                }
976
                // AfterFileValidate is after all files have been validated
977
                for (const file of afterValidateFiles) {
1,424✔
978
                    const validateFileEvent = {
2,054✔
979
                        program: this,
980
                        file: file
981
                    };
982
                    this.plugins.emit('afterFileValidate', validateFileEvent);
2,054✔
983
                }
984
            }).durationText;
985

986
            metrics.filesChanged = afterValidateFiles.length;
1,424✔
987

988
            const changedComponentTypes: string[] = [];
1,424✔
989

990
            // Build component types for any component that changes
991
            this.logger.time(LogLevel.info, ['Build component types'], () => {
1,424✔
992
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,424✔
993
                    if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
481✔
994
                        changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
341✔
995
                    }
996
                }
997
                this.componentSymbolsToUpdate.clear();
1,424✔
998
            });
999

1000
            // get set of changed symbols
1001
            const changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,424✔
1002
            metrics.changedSymbolsTime = validationStopwatch.getDurationTextFor(() => {
1,424✔
1003

1004
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,424!
1005
                    if (isBrsFile(f)) {
2,054✔
1006
                        return f.providedSymbols.changes;
1,704✔
1007
                    }
1008
                    return null;
350✔
1009
                }).filter(x => x);
2,054✔
1010

1011
                // update the map of typetime dependencies
1012
                for (const file of brsFilesValidated) {
1,424✔
1013
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,704✔
1014
                        // clear existing dependencies
1015
                        for (const values of this.symbolDependencies.values()) {
661✔
1016
                            values.delete(symbolName);
62✔
1017
                        }
1018

1019
                        // map types to the set of types that depend upon them
1020
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
661!
1021
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
182✔
1022
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
182✔
1023
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
160✔
1024
                            }
1025
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
182✔
1026
                            symbolsDependentUpon.add(symbolName);
182✔
1027
                        }
1028
                    }
1029
                }
1030

1031
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,424✔
1032
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,408✔
1033
                    const changedSymbolSet = new Set<string>();
2,848✔
1034
                    for (const changeSet of changedSymbolsSetArr) {
2,848✔
1035
                        for (const change of changeSet) {
3,408✔
1036
                            changedSymbolSet.add(change);
3,476✔
1037
                        }
1038
                    }
1039
                    changedSymbols.set(flag, changedSymbolSet);
2,848✔
1040
                }
1041

1042
                // update changed symbol set with any changed component
1043
                for (const changedComponentType of changedComponentTypes) {
1,424✔
1044
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
341✔
1045
                }
1046

1047
                // Add any additional types that depend on a changed type
1048
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1049
                const dependentTypesChanged = new Set<string>();
1,424✔
1050
                let foundDependentTypes = false;
1,424✔
1051
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,424✔
1052
                do {
1,424✔
1053
                    foundDependentTypes = false;
1,430✔
1054
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
1,430✔
1055
                    for (const changedSymbol of allChangedTypesSofar) {
1,430✔
1056
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,006✔
1057
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,006✔
1058
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
186✔
1059
                                foundDependentTypes = true;
6✔
1060
                                dependentTypesChanged.add(symbolName);
6✔
1061
                            }
1062
                        }
1063
                    }
1064
                } while (foundDependentTypes);
1065

1066
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedTypeSymbols, ...dependentTypesChanged]));
1,424✔
1067
            }).durationText;
1068

1069
            if (this.options.logLevel === LogLevel.debug) {
1,424!
1070
                const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
1071
                this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
1072
                const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
1073
                this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1074
            }
1075
            metrics.changedSymbols = changedSymbols.get(SymbolTypeFlag.runtime).size + changedSymbols.get(SymbolTypeFlag.typetime).size;
1,424✔
1076
            const filesToBeValidatedInScopeContext = new Set<BscFile>(afterValidateFiles);
1,424✔
1077

1078
            metrics.crossScopeValidationTime = validationStopwatch.getDurationTextFor(() => {
1,424✔
1079
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,424✔
1080
                this.crossScopeValidation.buildComponentsMap();
1,424✔
1081
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,424✔
1082
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,424✔
1083
                for (const file of filesToRevalidate) {
1,424✔
1084
                    filesToBeValidatedInScopeContext.add(file);
416✔
1085
                }
1086
            }).durationText;
1087

1088
            metrics.filesValidated = filesToBeValidatedInScopeContext.size;
1,424✔
1089

1090
            let linkTime = 0;
1,424✔
1091
            let validationTime = 0;
1,424✔
1092
            let scopesValidated = 0;
1,424✔
1093
            let changedFiles = new Set<BscFile>(afterValidateFiles);
1,424✔
1094
            this.currentScopeValidationOptions = {
1,424✔
1095
                filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1096
                changedSymbols: changedSymbols,
1097
                changedFiles: changedFiles,
1098
                initialValidation: this.isFirstValidation
1099
            };
1100
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
1,424✔
1101
                //sort the scope names so we get consistent results
1102
                const scopeNames = this.getSortedScopeNames();
1,424✔
1103
                for (const file of filesToBeValidatedInScopeContext) {
1,424✔
1104
                    if (isBrsFile(file)) {
2,183✔
1105
                        file.validationSegmenter.unValidateAllSegments();
1,833✔
1106
                        for (const scope of this.getScopesForFile(file)) {
1,833✔
1107
                            scope.invalidate();
2,129✔
1108
                        }
1109
                    }
1110
                }
1111
                for (let scopeName of scopeNames) {
1,424✔
1112
                    let scope = this.scopes[scopeName];
3,249✔
1113
                    const scopeValidated = scope.validate(this.currentScopeValidationOptions);
3,249✔
1114
                    if (scopeValidated) {
3,249✔
1115
                        scopesValidated++;
1,766✔
1116
                    }
1117
                    linkTime += scope.validationMetrics.linkTime;
3,249✔
1118
                    validationTime += scope.validationMetrics.validationTime;
3,249✔
1119
                }
1120
            });
1121
            metrics.scopesValidated = scopesValidated;
1,424✔
1122
            validationStopwatch.totalMilliseconds = linkTime;
1,424✔
1123
            metrics.totalLinkTime = validationStopwatch.getDurationText();
1,424✔
1124

1125
            validationStopwatch.totalMilliseconds = validationTime;
1,424✔
1126
            metrics.totalScopeValidationTime = validationStopwatch.getDurationText();
1,424✔
1127

1128
            metrics.componentValidationTime = validationStopwatch.getDurationTextFor(() => {
1,424✔
1129
                this.detectDuplicateComponentNames();
1,424✔
1130
            }).durationText;
1131

1132
            this.logValidationMetrics(metrics);
1,424✔
1133

1134
            this.isFirstValidation = false;
1,424✔
1135

1136
            this.plugins.emit('afterProgramValidate', programValidateEvent);
1,424✔
1137
        });
1138
    }
1139

1140
    // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
1141
    private logValidationMetrics(metrics: { [key: string]: number | string }) {
1142
        let logs = [] as string[];
1,424✔
1143
        for (const key in metrics) {
1,424✔
1144
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
14,240✔
1145
        }
1146
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
1,424✔
1147
    }
1148

1149
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1150
        const scopesForCrossScopeValidation = [];
1,424✔
1151
        for (let scopeName of this.getSortedScopeNames()) {
1,424✔
1152
            let scope = this.scopes[scopeName];
3,249✔
1153
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,249✔
1154
                scopesForCrossScopeValidation.push(scope);
1,799✔
1155
            }
1156
        }
1157
        return scopesForCrossScopeValidation;
1,424✔
1158
    }
1159

1160
    /**
1161
     * Flag all duplicate component names
1162
     */
1163
    private detectDuplicateComponentNames() {
1164
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,424✔
1165
            const file = this.files[filePath];
2,406✔
1166
            //if this is an XmlFile, and it has a valid `componentName` property
1167
            if (isXmlFile(file) && file.componentName?.text) {
2,406✔
1168
                let lowerName = file.componentName.text.toLowerCase();
491✔
1169
                if (!map[lowerName]) {
491✔
1170
                    map[lowerName] = [];
488✔
1171
                }
1172
                map[lowerName].push(file);
491✔
1173
            }
1174
            return map;
2,406✔
1175
        }, {});
1176

1177
        for (let name in componentsByName) {
1,424✔
1178
            const xmlFiles = componentsByName[name];
488✔
1179
            //add diagnostics for every duplicate component with this name
1180
            if (xmlFiles.length > 1) {
488✔
1181
                for (let xmlFile of xmlFiles) {
3✔
1182
                    const { componentName } = xmlFile;
6✔
1183
                    this.diagnostics.register({
6✔
1184
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1185
                        location: xmlFile.componentName.location,
1186
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1187
                            return {
6✔
1188
                                location: x.componentName.location,
1189
                                message: 'Also defined here'
1190
                            };
1191
                        })
1192
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1193
                }
1194
            }
1195
        }
1196
    }
1197

1198
    /**
1199
     * Get the files for a list of filePaths
1200
     * @param filePaths can be an array of srcPath or a destPath strings
1201
     * @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
1202
     */
1203
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1204
        return filePaths
29✔
1205
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1206
            .filter(file => file !== undefined) as T[];
39✔
1207
    }
1208

1209
    private getFilePathCache = new Map<string, { path: string; isDestMap?: boolean }>();
1,883✔
1210

1211
    /**
1212
     * Get the file at the given path
1213
     * @param filePath can be a srcPath or a destPath
1214
     * @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
1215
     */
1216
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
18,765✔
1217
        if (this.getFilePathCache.has(filePath)) {
25,420✔
1218
            const cachedFilePath = this.getFilePathCache.get(filePath);
14,958✔
1219
            if (cachedFilePath.isDestMap) {
14,958✔
1220
                return this.destMap.get(
12,672✔
1221
                    cachedFilePath.path
1222
                ) as T;
1223
            }
1224
            return this.files[
2,286✔
1225
                cachedFilePath.path
1226
            ] as T;
1227
        }
1228
        if (typeof filePath !== 'string') {
10,462✔
1229
            return undefined;
3,543✔
1230
            //is the path absolute (or the `virtual:` prefix)
1231
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
6,919✔
1232
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,356✔
1233
            this.getFilePathCache.set(filePath, { path: standardizedPath });
3,356✔
1234

1235
            return this.files[
3,356✔
1236
                standardizedPath
1237
            ] as T;
1238
        } else if (util.isUriLike(filePath)) {
3,563✔
1239
            const path = URI.parse(filePath).fsPath;
504✔
1240
            const standardizedPath = (normalizePath ? util.standardizePath(path) : path).toLowerCase();
504!
1241
            this.getFilePathCache.set(filePath, { path: standardizedPath });
504✔
1242

1243
            return this.files[
504✔
1244
                standardizedPath
1245
            ] as T;
1246
        } else {
1247
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,059✔
1248
            this.getFilePathCache.set(filePath, { path: standardizedPath, isDestMap: true });
3,059✔
1249
            return this.destMap.get(
3,059✔
1250
                standardizedPath
1251
            ) as T;
1252
        }
1253
    }
1254

1255
    private sortedScopeNames: string[] = undefined;
1,883✔
1256

1257
    /**
1258
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1259
     */
1260
    private getSortedScopeNames() {
1261
        if (!this.sortedScopeNames) {
11,273✔
1262
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,374✔
1263
                if (a === 'global') {
1,980!
1264
                    return -1;
×
1265
                } else if (b === 'global') {
1,980✔
1266
                    return 1;
1,357✔
1267
                }
1268
                if (a === 'source') {
623✔
1269
                    return -1;
28✔
1270
                } else if (b === 'source') {
595✔
1271
                    return 1;
149✔
1272
                }
1273
                if (a < b) {
446✔
1274
                    return -1;
193✔
1275
                } else if (b < a) {
253!
1276
                    return 1;
253✔
1277
                }
1278
                return 0;
×
1279
            });
1280
        }
1281
        return this.sortedScopeNames;
11,273✔
1282
    }
1283

1284
    /**
1285
     * Get a list of all scopes the file is loaded into
1286
     * @param file the file
1287
     */
1288
    public getScopesForFile(file: BscFile | string) {
1289
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,297✔
1290

1291
        let result = [] as Scope[];
4,297✔
1292
        if (resolvedFile) {
4,297✔
1293
            const scopeKeys = this.getSortedScopeNames();
4,296✔
1294
            for (let key of scopeKeys) {
4,296✔
1295
                let scope = this.scopes[key];
39,959✔
1296

1297
                if (scope.hasFile(resolvedFile)) {
39,959✔
1298
                    result.push(scope);
4,900✔
1299
                }
1300
            }
1301
        }
1302
        return result;
4,297✔
1303
    }
1304

1305
    /**
1306
     * Get the first found scope for a file.
1307
     */
1308
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1309
        const scopeKeys = this.getSortedScopeNames();
4,129✔
1310
        for (let key of scopeKeys) {
4,129✔
1311
            let scope = this.scopes[key];
18,669✔
1312

1313
            if (scope.hasFile(file)) {
18,669✔
1314
                return scope;
3,015✔
1315
            }
1316
        }
1317
    }
1318

1319
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1320
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1321
        const filesSearched = new Set<BrsFile>();
39✔
1322
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1323
        let lowerName = name?.toLowerCase();
39!
1324

1325
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1326
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1327
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1328
                if (!results.has(statement)) {
36!
1329
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1330
                }
1331
            }
1332
        }
1333

1334
        //look through all files in scope for matches
1335
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1336
            for (const file of scope.getAllFiles()) {
39✔
1337
                //skip non-brs files, or files we've already processed
1338
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1339
                    continue;
3✔
1340
                }
1341
                filesSearched.add(file);
42✔
1342

1343
                file.ast.walk(createVisitor({
42✔
1344
                    FunctionStatement: (statement: FunctionStatement) => {
1345
                        addToResults(statement, file);
95✔
1346
                    },
1347
                    MethodStatement: (statement: MethodStatement) => {
1348
                        addToResults(statement, file);
3✔
1349
                    }
1350
                }), {
1351
                    walkMode: WalkMode.visitStatements
1352
                });
1353
            }
1354
        }
1355
        return [...results.values()];
39✔
1356
    }
1357

1358
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1359
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1360
        const filesSearched = new Set<BrsFile>();
14✔
1361

1362
        //get all function names for the xml file and parents
1363
        let funcNames = new Set<string>();
14✔
1364
        let currentScope = scope;
14✔
1365
        while (isXmlScope(currentScope)) {
14✔
1366
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1367
                if (!filterName || name === filterName) {
20!
1368
                    funcNames.add(name);
20✔
1369
                }
1370
            }
1371
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1372
        }
1373

1374
        //look through all files in scope for matches
1375
        for (const file of scope.getOwnFiles()) {
14✔
1376
            //skip non-brs files, or files we've already processed
1377
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1378
                continue;
14✔
1379
            }
1380
            filesSearched.add(file);
14✔
1381

1382
            file.ast.walk(createVisitor({
14✔
1383
                FunctionStatement: (statement: FunctionStatement) => {
1384
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1385
                        if (!results.has(statement)) {
19!
1386
                            results.set(statement, { item: statement, file: file });
19✔
1387
                        }
1388
                    }
1389
                }
1390
            }), {
1391
                walkMode: WalkMode.visitStatements
1392
            });
1393
        }
1394
        return [...results.values()];
14✔
1395
    }
1396

1397
    /**
1398
     * Find all available completion items at the given position
1399
     * @param filePath can be a srcPath or a destPath
1400
     * @param position the position (line & column) where completions should be found
1401
     */
1402
    public getCompletions(filePath: string, position: Position) {
1403
        let file = this.getFile(filePath);
120✔
1404
        if (!file) {
120!
1405
            return [];
×
1406
        }
1407

1408
        //find the scopes for this file
1409
        let scopes = this.getScopesForFile(file);
120✔
1410

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

1414
        const event: ProvideCompletionsEvent = {
120✔
1415
            program: this,
1416
            file: file,
1417
            scopes: scopes,
1418
            position: position,
1419
            completions: []
1420
        };
1421

1422
        this.plugins.emit('beforeProvideCompletions', event);
120✔
1423

1424
        this.plugins.emit('provideCompletions', event);
120✔
1425

1426
        this.plugins.emit('afterProvideCompletions', event);
120✔
1427

1428
        return event.completions;
120✔
1429
    }
1430

1431
    /**
1432
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1433
     */
1434
    public getWorkspaceSymbols() {
1435
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1436
            program: this,
1437
            workspaceSymbols: []
1438
        };
1439
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1440
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1441
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1442
        return event.workspaceSymbols;
22✔
1443
    }
1444

1445
    /**
1446
     * Given a position in a file, if the position is sitting on some type of identifier,
1447
     * go to the definition of that identifier (where this thing was first defined)
1448
     */
1449
    public getDefinition(srcPath: string, position: Position): Location[] {
1450
        let file = this.getFile(srcPath);
18✔
1451
        if (!file) {
18!
1452
            return [];
×
1453
        }
1454

1455
        const event: ProvideDefinitionEvent = {
18✔
1456
            program: this,
1457
            file: file,
1458
            position: position,
1459
            definitions: []
1460
        };
1461

1462
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1463
        this.plugins.emit('provideDefinition', event);
18✔
1464
        this.plugins.emit('afterProvideDefinition', event);
18✔
1465
        return event.definitions;
18✔
1466
    }
1467

1468
    /**
1469
     * Get hover information for a file and position
1470
     */
1471
    public getHover(srcPath: string, position: Position): Hover[] {
1472
        let file = this.getFile(srcPath);
69✔
1473
        let result: Hover[];
1474
        if (file) {
69!
1475
            const event = {
69✔
1476
                program: this,
1477
                file: file,
1478
                position: position,
1479
                scopes: this.getScopesForFile(file),
1480
                hovers: []
1481
            } as ProvideHoverEvent;
1482
            this.plugins.emit('beforeProvideHover', event);
69✔
1483
            this.plugins.emit('provideHover', event);
69✔
1484
            this.plugins.emit('afterProvideHover', event);
69✔
1485
            result = event.hovers;
69✔
1486
        }
1487

1488
        return result ?? [];
69!
1489
    }
1490

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

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

1528
            const scopes = this.getScopesForFile(file);
12✔
1529

1530
            this.plugins.emit('onGetCodeActions', {
12✔
1531
                program: this,
1532
                file: file,
1533
                range: range,
1534
                diagnostics: diagnostics,
1535
                scopes: scopes,
1536
                codeActions: codeActions
1537
            });
1538
        }
1539
        return codeActions;
13✔
1540
    }
1541

1542
    /**
1543
     * Get semantic tokens for the specified file
1544
     */
1545
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1546
        const file = this.getFile(srcPath);
24✔
1547
        if (file) {
24!
1548
            const result = [] as SemanticToken[];
24✔
1549
            this.plugins.emit('onGetSemanticTokens', {
24✔
1550
                program: this,
1551
                file: file,
1552
                scopes: this.getScopesForFile(file),
1553
                semanticTokens: result
1554
            });
1555
            return result;
24✔
1556
        }
1557
    }
1558

1559
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1560
        let file: BrsFile = this.getFile(filepath);
185✔
1561
        if (!file || !isBrsFile(file)) {
185✔
1562
            return [];
3✔
1563
        }
1564
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1565
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1566
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1567
    }
1568

1569
    public getReferences(srcPath: string, position: Position): Location[] {
1570
        //find the file
1571
        let file = this.getFile(srcPath);
4✔
1572

1573
        const event: ProvideReferencesEvent = {
4✔
1574
            program: this,
1575
            file: file,
1576
            position: position,
1577
            references: []
1578
        };
1579

1580
        this.plugins.emit('beforeProvideReferences', event);
4✔
1581
        this.plugins.emit('provideReferences', event);
4✔
1582
        this.plugins.emit('afterProvideReferences', event);
4✔
1583

1584
        return event.references;
4✔
1585
    }
1586

1587
    /**
1588
     * Transpile a single file and get the result as a string.
1589
     * This does not write anything to the file system.
1590
     *
1591
     * This should only be called by `LanguageServer`.
1592
     * Internal usage should call `_getTranspiledFileContents` instead.
1593
     * @param filePath can be a srcPath or a destPath
1594
     */
1595
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1596
        const file = this.getFile(filePath);
319✔
1597

1598
        return this.getTranspiledFileContentsPipeline.run(async () => {
319✔
1599

1600
            const result = {
319✔
1601
                destPath: file.destPath,
1602
                pkgPath: file.pkgPath,
1603
                srcPath: file.srcPath
1604
            } as FileTranspileResult;
1605

1606
            const expectedPkgPath = file.pkgPath.toLowerCase();
319✔
1607
            const expectedMapPath = `${expectedPkgPath}.map`;
319✔
1608
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
319✔
1609

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

1636
            try {
319✔
1637
                //now that the plugin has been registered, run the build with just this file
1638
                await this.build({
319✔
1639
                    files: [file]
1640
                });
1641
            } finally {
1642
                this.plugins.remove(plugin);
319✔
1643
            }
1644
            return result;
319✔
1645
        });
1646
    }
1647
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,883✔
1648

1649
    /**
1650
     * Get the absolute output path for a file
1651
     */
1652
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1653
        return s`${stagingDir}/${file.pkgPath}`;
1,835✔
1654
    }
1655

1656
    private getStagingDir(stagingDir?: string) {
1657
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
719✔
1658
        if (!result) {
719✔
1659
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
531✔
1660
        }
1661
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
719!
1662
        return result;
719✔
1663
    }
1664

1665
    /**
1666
     * Prepare the program for building
1667
     * @param files the list of files that should be prepared
1668
     */
1669
    private async prepare(files: BscFile[]) {
1670
        const programEvent: PrepareProgramEvent = {
360✔
1671
            program: this,
1672
            editor: this.editor,
1673
            files: files
1674
        };
1675

1676
        //assign an editor to every file
1677
        for (const file of programEvent.files) {
360✔
1678
            //if the file doesn't have an editor yet, assign one now
1679
            if (!file.editor) {
730✔
1680
                file.editor = new Editor();
683✔
1681
            }
1682
        }
1683

1684
        //sort the entries to make transpiling more deterministic
1685
        programEvent.files.sort((a, b) => {
360✔
1686
            if (a.pkgPath < b.pkgPath) {
384✔
1687
                return -1;
324✔
1688
            } else if (a.pkgPath > b.pkgPath) {
60!
1689
                return 1;
60✔
1690
            } else {
1691
                return 1;
×
1692
            }
1693
        });
1694

1695
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
360✔
1696
        await this.plugins.emitAsync('prepareProgram', programEvent);
360✔
1697

1698
        const stagingDir = this.getStagingDir();
360✔
1699

1700
        const entries: TranspileObj[] = [];
360✔
1701

1702
        for (const file of files) {
360✔
1703
            const scope = this.getFirstScopeForFile(file);
730✔
1704
            //link the symbol table for all the files in this scope
1705
            scope?.linkSymbolTable();
730✔
1706

1707
            //if the file doesn't have an editor yet, assign one now
1708
            if (!file.editor) {
730!
1709
                file.editor = new Editor();
×
1710
            }
1711
            const event = {
730✔
1712
                program: this,
1713
                file: file,
1714
                editor: file.editor,
1715
                scope: scope,
1716
                outputPath: this.getOutputPath(file, stagingDir)
1717
            } as PrepareFileEvent & { outputPath: string };
1718

1719
            await this.plugins.emitAsync('beforePrepareFile', event);
730✔
1720
            await this.plugins.emitAsync('prepareFile', event);
730✔
1721
            await this.plugins.emitAsync('afterPrepareFile', event);
730✔
1722

1723
            //TODO remove this in v1
1724
            entries.push(event);
730✔
1725

1726
            //unlink the symbolTable so the next loop iteration can link theirs
1727
            scope?.unlinkSymbolTable();
730✔
1728
        }
1729

1730
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
360✔
1731
        return files;
360✔
1732
    }
1733

1734
    /**
1735
     * Generate the contents of every file
1736
     */
1737
    private async serialize(files: BscFile[]) {
1738

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

1741
        //exclude prunable files if that option is enabled
1742
        if (this.options.pruneEmptyCodeFiles === true) {
359✔
1743
            files = files.filter(x => x.canBePruned !== true);
9✔
1744
        }
1745

1746
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
359✔
1747
            program: this,
1748
            files: files,
1749
            result: allFiles
1750
        });
1751
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
359✔
1752

1753
        // serialize each file
1754
        for (const file of files) {
359✔
1755
            let scope = this.getFirstScopeForFile(file);
727✔
1756

1757
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1758
            if (!scope) {
727✔
1759
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
370✔
1760
                scope.getAllFiles = () => [file];
3,317✔
1761
                scope.getOwnFiles = scope.getAllFiles;
370✔
1762
            }
1763

1764
            //link the symbol table for all the files in this scope
1765
            scope?.linkSymbolTable();
727!
1766
            const event: SerializeFileEvent = {
727✔
1767
                program: this,
1768
                file: file,
1769
                scope: scope,
1770
                result: allFiles
1771
            };
1772
            await this.plugins.emitAsync('beforeSerializeFile', event);
727✔
1773
            await this.plugins.emitAsync('serializeFile', event);
727✔
1774
            await this.plugins.emitAsync('afterSerializeFile', event);
727✔
1775
            //unlink the symbolTable so the next loop iteration can link theirs
1776
            scope?.unlinkSymbolTable();
727!
1777
        }
1778

1779
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
359✔
1780

1781
        return allFiles;
359✔
1782
    }
1783

1784
    /**
1785
     * Write the entire project to disk
1786
     */
1787
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1788
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
359✔
1789
            program: this,
1790
            files: files,
1791
            stagingDir: stagingDir
1792
        });
1793
        //empty the staging directory
1794
        await fsExtra.emptyDir(stagingDir);
359✔
1795

1796
        const serializedFiles = [...files]
359✔
1797
            .map(([, serializedFiles]) => serializedFiles)
727✔
1798
            .flat();
1799

1800
        //write all the files to disk (asynchronously)
1801
        await Promise.all(
359✔
1802
            serializedFiles.map(async (file) => {
1803
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,105✔
1804
                    program: this,
1805
                    file: file,
1806
                    outputPath: this.getOutputPath(file, stagingDir),
1807
                    processedFiles: new Set<SerializedFile>()
1808
                });
1809

1810
                await this.plugins.emitAsync('writeFile', event);
1,105✔
1811

1812
                await this.plugins.emitAsync('afterWriteFile', event);
1,105✔
1813
            })
1814
        );
1815

1816
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
359✔
1817
    }
1818

1819
    private buildPipeline = new ActionPipeline();
1,883✔
1820

1821
    /**
1822
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1823
     * @param options the list of options used to build the program
1824
     */
1825
    public async build(options?: ProgramBuildOptions) {
1826
        //run a single build at a time
1827
        await this.buildPipeline.run(async () => {
359✔
1828
            const stagingDir = this.getStagingDir(options?.stagingDir);
359✔
1829

1830
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
359✔
1831
                program: this,
1832
                editor: this.editor,
1833
                files: options?.files ?? Object.values(this.files)
2,154✔
1834
            });
1835

1836
            //prepare the program (and files) for building
1837
            event.files = await this.prepare(event.files);
359✔
1838

1839
            //stage the entire program
1840
            const serializedFilesByFile = await this.serialize(event.files);
359✔
1841

1842
            await this.write(stagingDir, serializedFilesByFile);
359✔
1843

1844
            await this.plugins.emitAsync('afterBuildProgram', event);
359✔
1845

1846
            //undo all edits for the program
1847
            this.editor.undoAll();
359✔
1848
            //undo all edits for each file
1849
            for (const file of event.files) {
359✔
1850
                file.editor.undoAll();
728✔
1851
            }
1852
        });
1853

1854
        console.log('TYPES CREATED', TypesCreated);
359✔
1855
        let totalTypesCreated = 0;
359✔
1856
        for (const key in TypesCreated) {
359✔
1857
            if (TypesCreated.hasOwnProperty(key)) {
9,326!
1858
                totalTypesCreated += TypesCreated[key];
9,326✔
1859

1860
            }
1861
        }
1862
        console.log('TOTAL TYPES CREATED', totalTypesCreated);
359✔
1863
    }
1864

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

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

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

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

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

1928
        return files;
7✔
1929
    }
1930

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

1946
    private _manifest: Map<string, string>;
1947

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

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

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

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

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

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

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

2011
    public dispose() {
2012
        this.plugins.emit('beforeProgramDispose', { program: this });
1,721✔
2013

2014
        for (let filePath in this.files) {
1,721✔
2015
            this.files[filePath]?.dispose?.();
2,158!
2016
        }
2017
        for (let name in this.scopes) {
1,721✔
2018
            this.scopes[name]?.dispose?.();
3,641!
2019
        }
2020
        this.globalScope?.dispose?.();
1,721!
2021
        this.dependencyGraph?.dispose?.();
1,721!
2022
    }
2023
}
2024

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

2034

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

2046
    public srcExtension: string;
2047

2048
    public files: TFile[] = [];
2,492✔
2049
}
2050

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