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

rokucommunity / brighterscript / #14372

08 May 2025 07:58PM UTC coverage: 87.082% (+0.006%) from 87.076%
#14372

push

web-flow
Merge 07268ef5d into 31e932469

13530 of 16428 branches covered (82.36%)

Branch coverage included in aggregate %.

27 of 29 new or added lines in 4 files covered. (93.1%)

65 existing lines in 4 files now uncovered.

14486 of 15744 relevant lines covered (92.01%)

19985.12 hits per line

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

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

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

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

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

86
        //try to find a location for the diagnostic if it doesn't have one
87
        this.diagnostics.locationResolver = (args) => {
1,985✔
88

89
            //find the first xml scope for this diagnostic
90
            for (let context of args.contexts) {
5✔
91
                if (isXmlScope(context.scope) && isXmlFile(context.scope.xmlFile)) {
1!
92
                    return util.createLocation(0, 0, 0, 100, context.scope.xmlFile.srcPath);
1✔
93
                }
94
            }
95

96
            //we couldn't find an xml scope for this, so try to find the manifest file instead
97
            const manifest = this.getFile('manifest', false);
4✔
98
            if (manifest) {
4✔
99
                return util.createLocation(0, 0, 0, 100, manifest.srcPath);
2✔
100
            }
101

102
            //if we still don't have a manifest, try to find the first file in the program
103
            for (const key in this.files) {
2✔
104
                if (isBrsFile(this.files[key]) || isXmlFile(this.files[key])) {
1!
105
                    return util.createLocation(0, 0, 0, 100, this.files[key].srcPath);
1✔
106
                }
107
            }
108

109
            this.logger.warn(`Unable to find a location for the diagnostic.`, args);
1✔
110

111
            //we couldn't find any locations for the file, so just return undefined
112
            return undefined;
1✔
113
        };
114

115
        // initialize the diagnostics Manager
116
        this.diagnostics.logger = this.logger;
1,985✔
117
        this.diagnostics.options = this.options;
1,985✔
118
        this.diagnostics.program = this;
1,985✔
119

120
        //inject the bsc plugin as the first plugin in the stack.
121
        this.plugins.addFirst(new BscPlugin());
1,985✔
122

123
        //normalize the root dir path
124
        this.options.rootDir = util.getRootDir(this.options);
1,985✔
125

126
        this.createGlobalScope();
1,985✔
127

128
        this.fileFactory = new FileFactory(this);
1,985✔
129
    }
130

131
    public options: FinalizedBsConfig;
132
    public logger: Logger;
133

134
    /**
135
     * 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`)
136
     */
137
    public editor = new Editor();
1,985✔
138

139
    /**
140
     * A factory that creates `File` instances
141
     */
142
    private fileFactory: FileFactory;
143

144
    private createGlobalScope() {
145
        //create the 'global' scope
146
        this.globalScope = new Scope('global', this, 'scope:global');
1,985✔
147
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,985✔
148
        this.scopes.global = this.globalScope;
1,985✔
149

150
        this.populateGlobalSymbolTable();
1,985✔
151
        this.globalScope.symbolTable.addSibling(this.componentsTable);
1,985✔
152

153
        //hardcode the files list for global scope to only contain the global file
154
        this.globalScope.getAllFiles = () => [globalFile];
20,518✔
155
        globalFile.isValidated = true;
1,985✔
156
        this.globalScope.validate();
1,985✔
157

158
        //TODO we might need to fix this because the isValidated clears stuff now
159
        (this.globalScope as any).isValidated = true;
1,985✔
160
    }
161

162

163
    private recursivelyAddNodeToSymbolTable(nodeData: SGNodeData) {
164
        if (!nodeData) {
371,195!
UNCOV
165
            return;
×
166
        }
167
        let nodeType: ComponentType;
168
        const nodeName = util.getSgNodeTypeName(nodeData.name);
371,195✔
169
        if (!this.globalScope.symbolTable.hasSymbol(nodeName, SymbolTypeFlag.typetime)) {
371,195✔
170
            let parentNode: ComponentType;
171
            if (nodeData.extends) {
192,545✔
172
                const parentNodeData = nodes[nodeData.extends.name.toLowerCase()];
178,650✔
173
                try {
178,650✔
174
                    parentNode = this.recursivelyAddNodeToSymbolTable(parentNodeData);
178,650✔
175
                } catch (error) {
UNCOV
176
                    this.logger.error(error, nodeData);
×
177
                }
178
            }
179
            nodeType = new ComponentType(nodeData.name, parentNode);
192,545✔
180
            nodeType.addBuiltInInterfaces();
192,545✔
181
            nodeType.isBuiltIn = true;
192,545✔
182
            if (nodeData.name === 'Node') {
192,545✔
183
                // Add `roSGNode` as shorthand for `roSGNodeNode`
184
                this.globalScope.symbolTable.addSymbol('roSGNode', { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
1,985✔
185
            }
186
            this.globalScope.symbolTable.addSymbol(nodeName, { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
192,545✔
187
        } else {
188
            nodeType = this.globalScope.symbolTable.getSymbolType(nodeName, { flags: SymbolTypeFlag.typetime }) as ComponentType;
178,650✔
189
        }
190

191
        return nodeType;
371,195✔
192
    }
193
    /**
194
     * Do all setup required for the global symbol table.
195
     */
196
    private populateGlobalSymbolTable() {
197
        //Setup primitive types in global symbolTable
198

199
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
1,985✔
200

201
        this.globalScope.symbolTable.addSymbol('boolean', builtInSymbolData, BooleanType.instance, SymbolTypeFlag.typetime);
1,985✔
202
        this.globalScope.symbolTable.addSymbol('double', builtInSymbolData, DoubleType.instance, SymbolTypeFlag.typetime);
1,985✔
203
        this.globalScope.symbolTable.addSymbol('dynamic', builtInSymbolData, DynamicType.instance, SymbolTypeFlag.typetime);
1,985✔
204
        this.globalScope.symbolTable.addSymbol('float', builtInSymbolData, FloatType.instance, SymbolTypeFlag.typetime);
1,985✔
205
        this.globalScope.symbolTable.addSymbol('function', builtInSymbolData, FunctionType.instance, SymbolTypeFlag.typetime);
1,985✔
206
        this.globalScope.symbolTable.addSymbol('integer', builtInSymbolData, IntegerType.instance, SymbolTypeFlag.typetime);
1,985✔
207
        this.globalScope.symbolTable.addSymbol('longinteger', builtInSymbolData, LongIntegerType.instance, SymbolTypeFlag.typetime);
1,985✔
208
        this.globalScope.symbolTable.addSymbol('object', builtInSymbolData, ObjectType.instance, SymbolTypeFlag.typetime);
1,985✔
209
        this.globalScope.symbolTable.addSymbol('string', builtInSymbolData, StringType.instance, SymbolTypeFlag.typetime);
1,985✔
210
        this.globalScope.symbolTable.addSymbol('void', builtInSymbolData, VoidType.instance, SymbolTypeFlag.typetime);
1,985✔
211

212
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
581,621✔
213

214
        for (const callable of globalCallables) {
1,985✔
215
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
154,830✔
216
        }
217

218
        for (const ifaceData of Object.values(interfaces) as BRSInterfaceData[]) {
1,985✔
219
            const nodeType = new InterfaceType(ifaceData.name);
174,680✔
220
            nodeType.addBuiltInInterfaces();
174,680✔
221
            nodeType.isBuiltIn = true;
174,680✔
222
            this.globalScope.symbolTable.addSymbol(ifaceData.name, { ...builtInSymbolData, description: ifaceData.description }, nodeType, SymbolTypeFlag.typetime);
174,680✔
223
        }
224

225
        for (const componentData of Object.values(components) as BRSComponentData[]) {
1,985✔
226
            const nodeType = new InterfaceType(componentData.name);
129,025✔
227
            nodeType.addBuiltInInterfaces();
129,025✔
228
            nodeType.isBuiltIn = true;
129,025✔
229
            if (componentData.name !== 'roSGNode') {
129,025✔
230
                // we will add `roSGNode` as shorthand for `roSGNodeNode`, since all roSgNode components are SceneGraph nodes
231
                this.globalScope.symbolTable.addSymbol(componentData.name, { ...builtInSymbolData, description: componentData.description }, nodeType, SymbolTypeFlag.typetime);
127,040✔
232
            }
233
        }
234

235
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,985✔
236
            this.recursivelyAddNodeToSymbolTable(nodeData);
192,545✔
237
        }
238

239
        for (const eventData of Object.values(events) as BRSEventData[]) {
1,985✔
240
            const nodeType = new InterfaceType(eventData.name);
35,730✔
241
            nodeType.addBuiltInInterfaces();
35,730✔
242
            nodeType.isBuiltIn = true;
35,730✔
243
            this.globalScope.symbolTable.addSymbol(eventData.name, { ...builtInSymbolData, description: eventData.description }, nodeType, SymbolTypeFlag.typetime);
35,730✔
244
        }
245

246
    }
247

248
    /**
249
     * A graph of all files and their dependencies.
250
     * For example:
251
     *      File.xml -> [lib1.brs, lib2.brs]
252
     *      lib2.brs -> [lib3.brs] //via an import statement
253
     */
254
    private dependencyGraph = new DependencyGraph();
1,985✔
255

256
    public diagnostics: DiagnosticManager;
257

258
    /**
259
     * A scope that contains all built-in global functions.
260
     * All scopes should directly or indirectly inherit from this scope
261
     */
262
    public globalScope: Scope = undefined as any;
1,985✔
263

264
    /**
265
     * Plugins which can provide extra diagnostics or transform AST
266
     */
267
    public plugins: PluginInterface;
268

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

271
    private currentScopeValidationOptions: ScopeValidationOptions;
272

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

278

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

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

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

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

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

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

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

324

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

619
        return file;
2,663✔
620
    }
621

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

838
    /**
839
     * Remove a set of files from the program
840
     * @param srcPaths can be an array of srcPath or destPath strings
841
     * @param normalizePath should this function repair and standardize the filePaths? Passing false should have a performance boost if you can guarantee your paths are already sanitized
842
     */
843
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
844
        for (let srcPath of srcPaths) {
1✔
845
            this.removeFile(srcPath, normalizePath);
1✔
846
        }
847
    }
848

849
    /**
850
     * Remove a file from the program
851
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
852
     * @param normalizePath should this function repair and standardize the path? Passing false should have a performance boost if you can guarantee your path is already sanitized
853
     */
854
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
32✔
855
        this.logger.debug('Program.removeFile()', filePath);
216✔
856
        const paths = this.getPaths(filePath, this.options.rootDir);
216✔
857

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

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

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

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

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

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

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

905
            this.diagnostics.clearForFile(file.srcPath);
218✔
906

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

911
                this.unregisterComponent(file);
16✔
912
            }
913
            this.logger.debug('Disposing file', file.srcPath);
218✔
914

915
            //dispose any disposable things on the file
916
            for (const disposable of file?.disposables ?? []) {
218!
917
                disposable();
211✔
918
            }
919
            //dispose file
920
            file?.dispose?.();
218!
921

922
            this.plugins.emit('afterFileRemove', event);
218✔
923
        }
924
    }
925

926
    public crossScopeValidation = new CrossScopeValidator(this);
1,985✔
927

928
    private isFirstValidation = true;
1,985✔
929

930
    private validationDetails: {
1,985✔
931
        brsFilesValidated: BrsFile[];
932
        xmlFilesValidated: XmlFile[];
933
        changedSymbols: Map<SymbolTypeFlag, Set<string>>;
934
        changedComponentTypes: string[];
935
        scopesToValidate: Scope[];
936
        filesToBeValidatedInScopeContext: Set<BscFile>;
937

938
    } = {
939
            brsFilesValidated: [],
940
            xmlFilesValidated: [],
941
            changedSymbols: new Map<SymbolTypeFlag, Set<string>>(),
942
            changedComponentTypes: [],
943
            scopesToValidate: [],
944
            filesToBeValidatedInScopeContext: new Set<BscFile>()
945
        };
946

947
    /**
948
     * Counter used to track which validation run is being logged
949
     */
950
    private validationRunSequence = 1;
1,985✔
951

952
    /**
953
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
954
     */
955
    private validationMinSyncDuration = 75;
1,985✔
956

957
    private validatePromise: Promise<void> | undefined;
958

959
    /**
960
     * Traverse the entire project, and validate all scopes
961
     */
962
    public validate(): void;
963
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
964
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
965
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
966
        const validationRunId = this.validationRunSequence++;
1,549✔
967

968
        let previousValidationPromise = this.validatePromise;
1,549✔
969
        const deferred = new Deferred();
1,549✔
970

971
        if (options?.async) {
1,549✔
972
            //we're async, so create a new promise chain to resolve after this validation is done
973
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
130✔
974
                return deferred.promise;
130✔
975
            });
976

977
            //we are not async but there's a pending promise, then we cannot run this validation
978
        } else if (previousValidationPromise !== undefined) {
1,419!
UNCOV
979
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
980
        }
981

982
        let beforeProgramValidateWasEmitted = false;
1,549✔
983

984
        const brsFilesValidated: BrsFile[] = this.validationDetails.brsFilesValidated;
1,549✔
985
        const xmlFilesValidated: XmlFile[] = this.validationDetails.xmlFilesValidated;
1,549✔
986
        const changedSymbols = this.validationDetails.changedSymbols;
1,549✔
987
        const changedComponentTypes = this.validationDetails.changedComponentTypes;
1,549✔
988
        const scopesToValidate = this.validationDetails.scopesToValidate;
1,549✔
989
        const filesToBeValidatedInScopeContext = this.validationDetails.filesToBeValidatedInScopeContext;
1,549✔
990

991
        //validate every file
992

993
        let logValidateEnd = (status?: string) => { };
1,549✔
994

995
        //will be populated later on during the correspnding sequencer event
996
        let filesToProcess: BscFile[];
997

998
        const sequencer = new Sequencer({
1,549✔
999
            name: 'program.validate',
1000
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
9,294✔
1001
            minSyncDuration: this.validationMinSyncDuration
1002
        });
1003
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
1004
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
1005
        sequencer
1,549✔
1006
            .once('wait for previous run', () => {
1007
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
1008
                if (options?.async) {
1,549✔
1009
                    return previousValidationPromise;
130✔
1010
                }
1011
            })
1012
            .once('before and on programValidate', () => {
1013
                logValidateEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
1,545!
1014
                this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,545✔
1015
                this.plugins.emit('beforeProgramValidate', {
1,545✔
1016
                    program: this
1017
                });
1018
                beforeProgramValidateWasEmitted = true;
1,545✔
1019
                this.plugins.emit('onProgramValidate', {
1,545✔
1020
                    program: this
1021
                });
1022
            })
1023
            //handle some component symbol stuff
1024
            .forEach('addDeferredComponentTypeSymbolCreation',
1025
                () => {
1026
                    filesToProcess = Object.values(this.files).sort(firstBy(x => x.srcPath)).filter(x => !x.isValidated);
4,298✔
1027
                    for (const file of filesToProcess) {
1,545✔
1028
                        filesToBeValidatedInScopeContext.add(file);
2,225✔
1029
                    }
1030

1031
                    //return the list of files that need to be processed
1032
                    return filesToProcess;
1,545✔
1033
                }, (file) => {
1034
                    // cast a wide net for potential changes in components
1035
                    if (isXmlFile(file)) {
2,224✔
1036
                        this.addDeferredComponentTypeSymbolCreation(file);
394✔
1037
                    } else if (isBrsFile(file)) {
1,830!
1038
                        for (const scope of this.getScopesForFile(file)) {
1,830✔
1039
                            if (isXmlScope(scope)) {
2,106✔
1040
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
627✔
1041
                            }
1042
                        }
1043
                    }
1044
                }
1045
            )
1046
            .once('addComponentReferenceTypes', () => {
1047
                // Create reference component types for any component that changes
1048
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,542✔
1049
                    this.addComponentReferenceType(componentKey, componentName);
531✔
1050
                }
1051
            })
1052
            .forEach('beforeFileValidate', () => filesToProcess, (file) => {
1,542✔
1053
                //run the beforeFilevalidate event for every unvalidated file
1054
                this.plugins.emit('beforeFileValidate', {
2,224✔
1055
                    program: this,
1056
                    file: file
1057
                });
1058
            })
1059
            .forEach('onFileValidate', () => filesToProcess, (file) => {
1,542✔
1060
                //run the onFileValidate event for every unvalidated file
1061
                this.plugins.emit('onFileValidate', {
2,224✔
1062
                    program: this,
1063
                    file: file
1064
                });
1065
                file.isValidated = true;
2,224✔
1066
                if (isBrsFile(file)) {
2,224✔
1067
                    brsFilesValidated.push(file);
1,830✔
1068
                } else if (isXmlFile(file)) {
394!
1069
                    xmlFilesValidated.push(file);
394✔
1070
                }
1071
            })
1072
            .forEach('afterFileValidate', () => filesToProcess, (file) => {
1,542✔
1073
                //run the onFileValidate event for every unvalidated file
1074
                this.plugins.emit('afterFileValidate', {
2,224✔
1075
                    program: this,
1076
                    file: file
1077
                });
1078
            })
1079
            .once('Build component types for any component that changes', () => {
1080
                this.logger.time(LogLevel.info, ['Build component types'], () => {
1,541✔
1081
                    for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,541✔
1082
                        if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
531✔
1083
                            changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
385✔
1084
                        }
1085
                    }
1086
                    this.componentSymbolsToUpdate.clear();
1,541✔
1087
                });
1088
            })
1089
            .once('track and update type-time and runtime symbol dependencies and changes', () => {
1090
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,541!
1091
                    if (isBrsFile(f)) {
2,224✔
1092
                        return f.providedSymbols.changes;
1,830✔
1093
                    }
1094
                    return null;
394✔
1095
                }).filter(x => x);
2,224✔
1096

1097
                // update the map of typetime dependencies
1098
                for (const file of brsFilesValidated) {
1,541✔
1099
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,830✔
1100
                        // clear existing dependencies
1101
                        for (const values of this.symbolDependencies.values()) {
674✔
1102
                            values.delete(symbolName);
62✔
1103
                        }
1104

1105
                        // map types to the set of types that depend upon them
1106
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
674!
1107
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
185✔
1108
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
185✔
1109
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
163✔
1110
                            }
1111
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
185✔
1112
                            symbolsDependentUpon.add(symbolName);
185✔
1113
                        }
1114
                    }
1115
                }
1116

1117
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,541✔
1118
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,660✔
1119
                    const changedSymbolSet = new Set<string>();
3,082✔
1120
                    for (const changeSet of changedSymbolsSetArr) {
3,082✔
1121
                        for (const change of changeSet) {
3,660✔
1122
                            changedSymbolSet.add(change);
3,614✔
1123
                        }
1124
                    }
1125
                    if (!changedSymbols.has(flag)) {
3,082✔
1126
                        changedSymbols.set(flag, changedSymbolSet);
3,078✔
1127
                    } else {
1128
                        changedSymbols.set(flag, new Set([...changedSymbols.get(flag), ...changedSymbolSet]));
4✔
1129
                    }
1130
                }
1131

1132
                // update changed symbol set with any changed component
1133
                for (const changedComponentType of changedComponentTypes) {
1,541✔
1134
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
385✔
1135
                }
1136

1137
                // Add any additional types that depend on a changed type
1138
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1139
                const dependentTypesChanged = new Set<string>();
1,541✔
1140
                let foundDependentTypes = false;
1,541✔
1141
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,541✔
1142
                do {
1,541✔
1143
                    foundDependentTypes = false;
1,547✔
1144
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
1,547✔
1145
                    for (const changedSymbol of allChangedTypesSofar) {
1,547✔
1146
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,062✔
1147
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,062✔
1148
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
189✔
1149
                                foundDependentTypes = true;
6✔
1150
                                dependentTypesChanged.add(symbolName);
6✔
1151
                            }
1152
                        }
1153
                    }
1154
                } while (foundDependentTypes);
1155

1156
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedSymbols.get(SymbolTypeFlag.typetime), ...changedTypeSymbols, ...dependentTypesChanged]));
1,541✔
1157

1158
                // can reset filesValidatedList, because they are no longer needed
1159
                this.validationDetails.brsFilesValidated = [];
1,541✔
1160
                this.validationDetails.xmlFilesValidated = [];
1,541✔
1161
            })
1162
            .once('tracks changed symbols and prepares files and scopes for validation.', () => {
1163
                if (this.options.logLevel === LogLevel.debug) {
1,541!
UNCOV
1164
                    const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
UNCOV
1165
                    this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
UNCOV
1166
                    const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
UNCOV
1167
                    this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1168
                }
1169

1170
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,541✔
1171
                this.crossScopeValidation.buildComponentsMap();
1,541✔
1172
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,541✔
1173
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,541✔
1174
                for (const file of filesToRevalidate) {
1,541✔
1175
                    filesToBeValidatedInScopeContext.add(file);
421✔
1176
                }
1177

1178
                this.currentScopeValidationOptions = {
1,541✔
1179
                    filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1180
                    changedSymbols: changedSymbols,
1181
                    changedFiles: Array.from(filesToBeValidatedInScopeContext),
1182
                    initialValidation: this.isFirstValidation
1183
                };
1184

1185
                //can reset changedComponent types
1186
                this.validationDetails.changedComponentTypes = [];
1,541✔
1187
            })
1188
            .forEach('invalidate affected scopes', () => filesToBeValidatedInScopeContext, (file) => {
1,541✔
1189
                if (isBrsFile(file)) {
2,354✔
1190
                    file.validationSegmenter.unValidateAllSegments();
1,960✔
1191
                    for (const scope of this.getScopesForFile(file)) {
1,960✔
1192
                        scope.invalidate();
2,237✔
1193
                    }
1194
                }
1195
            })
1196
            .forEach('validate scopes', () => this.getSortedScopeNames(), (scopeName) => {
1,541✔
1197
                //sort the scope names so we get consistent results
1198
                let scope = this.scopes[scopeName];
3,514✔
1199
                if (scope.shouldValidate(this.currentScopeValidationOptions)) {
3,514✔
1200
                    scopesToValidate.push(scope);
1,914✔
1201
                    this.plugins.emit('beforeScopeValidate', {
1,914✔
1202
                        program: this,
1203
                        scope: scope
1204
                    });
1205
                }
1206
            })
1207
            .forEach('validate scope', () => this.getSortedScopeNames(), (scopeName) => {
1,539✔
1208
                //sort the scope names so we get consistent results
1209
                let scope = this.scopes[scopeName];
3,509✔
1210
                scope.validate(this.currentScopeValidationOptions);
3,509✔
1211
            })
1212
            .forEach('afterScopeValidate', () => scopesToValidate, (scope) => {
1,535✔
1213
                this.plugins.emit('afterScopeValidate', {
1,910✔
1214
                    program: this,
1215
                    scope: scope
1216
                });
1217
            })
1218
            .once('detect duplicate component names', () => {
1219
                this.detectDuplicateComponentNames();
1,534✔
1220
                this.isFirstValidation = false;
1,534✔
1221

1222
                // can reset other validation details
1223
                this.validationDetails.changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,534✔
1224
                this.validationDetails.scopesToValidate = [];
1,534✔
1225
                this.validationDetails.filesToBeValidatedInScopeContext = new Set<BscFile>();
1,534✔
1226

1227
            })
1228
            .onCancel(() => {
1229
                logValidateEnd('cancelled');
15✔
1230
            })
1231
            .onSuccess(() => {
1232
                logValidateEnd();
1,534✔
1233
            })
1234
            .onComplete(() => {
1235
                //if we emitted the beforeProgramValidate hook, emit the afterProgramValidate hook as well
1236
                if (beforeProgramValidateWasEmitted) {
1,549✔
1237
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
1,545✔
1238
                    this.plugins.emit('afterProgramValidate', {
1,545✔
1239
                        program: this,
1240
                        wasCancelled: wasCancelled
1241
                    });
1242
                }
1243

1244
                //log all the sequencer timing metrics if `info` logging is enabled
1245
                this.logger.info(
1,549✔
1246
                    sequencer.formatMetrics({
1247
                        header: 'Program.validate metrics:',
1248
                        //only include loop iterations if `debug` logging is enabled
1249
                        includeLoopIterations: this.logger.isLogLevelEnabled(LogLevel.debug)
1250
                    })
1251
                );
1252

1253
                //regardless of the success of the validation, mark this run as complete
1254
                deferred.resolve();
1,549✔
1255
                //clear the validatePromise which means we're no longer running a validation
1256
                this.validatePromise = undefined;
1,549✔
1257
            });
1258

1259
        //run the sequencer in async mode if enabled
1260
        if (options?.async) {
1,549✔
1261
            return sequencer.run();
130✔
1262

1263
            //run the sequencer in sync mode
1264
        } else {
1265
            return sequencer.runSync();
1,419✔
1266
        }
1267
    }
1268

1269
    protected logValidationMetrics(metrics: Record<string, number | string>) {
UNCOV
1270
        let logs = [] as string[];
×
UNCOV
1271
        for (const key in metrics) {
×
UNCOV
1272
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
×
1273
        }
UNCOV
1274
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
×
1275
    }
1276

1277
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1278
        const scopesForCrossScopeValidation = [];
1,541✔
1279
        for (let scopeName of this.getSortedScopeNames()) {
1,541✔
1280
            let scope = this.scopes[scopeName];
3,514✔
1281
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,514✔
1282
                scopesForCrossScopeValidation.push(scope);
1,930✔
1283
            }
1284
        }
1285
        return scopesForCrossScopeValidation;
1,541✔
1286
    }
1287

1288
    /**
1289
     * Flag all duplicate component names
1290
     */
1291
    private detectDuplicateComponentNames() {
1292
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,534✔
1293
            const file = this.files[filePath];
2,599✔
1294
            //if this is an XmlFile, and it has a valid `componentName` property
1295
            if (isXmlFile(file) && file.componentName?.text) {
2,599✔
1296
                let lowerName = file.componentName.text.toLowerCase();
558✔
1297
                if (!map[lowerName]) {
558✔
1298
                    map[lowerName] = [];
555✔
1299
                }
1300
                map[lowerName].push(file);
558✔
1301
            }
1302
            return map;
2,599✔
1303
        }, {});
1304

1305
        for (let name in componentsByName) {
1,534✔
1306
            const xmlFiles = componentsByName[name];
555✔
1307
            //add diagnostics for every duplicate component with this name
1308
            if (xmlFiles.length > 1) {
555✔
1309
                for (let xmlFile of xmlFiles) {
3✔
1310
                    const { componentName } = xmlFile;
6✔
1311
                    this.diagnostics.register({
6✔
1312
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1313
                        location: xmlFile.componentName.location,
1314
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1315
                            return {
6✔
1316
                                location: x.componentName.location,
1317
                                message: 'Also defined here'
1318
                            };
1319
                        })
1320
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1321
                }
1322
            }
1323
        }
1324
    }
1325

1326
    /**
1327
     * Get the files for a list of filePaths
1328
     * @param filePaths can be an array of srcPath or a destPath strings
1329
     * @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
1330
     */
1331
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1332
        return filePaths
29✔
1333
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1334
            .filter(file => file !== undefined) as T[];
39✔
1335
    }
1336

1337
    private getFilePathCache = new Map<string, { path: string; isDestMap?: boolean }>();
1,985✔
1338

1339
    /**
1340
     * Get the file at the given path
1341
     * @param filePath can be a srcPath or a destPath
1342
     * @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
1343
     */
1344
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
20,082✔
1345
        if (this.getFilePathCache.has(filePath)) {
27,018✔
1346
            const cachedFilePath = this.getFilePathCache.get(filePath);
15,959✔
1347
            if (cachedFilePath.isDestMap) {
15,959✔
1348
                return this.destMap.get(
13,196✔
1349
                    cachedFilePath.path
1350
                ) as T;
1351
            }
1352
            return this.files[
2,763✔
1353
                cachedFilePath.path
1354
            ] as T;
1355
        }
1356
        if (typeof filePath !== 'string') {
11,059✔
1357
            return undefined;
3,667✔
1358
            //is the path absolute (or the `virtual:` prefix)
1359
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
7,392✔
1360
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,531!
1361
            this.getFilePathCache.set(filePath, { path: standardizedPath });
3,531✔
1362

1363
            return this.files[
3,531✔
1364
                standardizedPath
1365
            ] as T;
1366
        } else if (util.isUriLike(filePath)) {
3,861✔
1367
            const path = URI.parse(filePath).fsPath;
594✔
1368
            const standardizedPath = (normalizePath ? util.standardizePath(path) : path).toLowerCase();
594!
1369
            this.getFilePathCache.set(filePath, { path: standardizedPath });
594✔
1370

1371
            return this.files[
594✔
1372
                standardizedPath
1373
            ] as T;
1374
        } else {
1375
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
3,267✔
1376
            this.getFilePathCache.set(filePath, { path: standardizedPath, isDestMap: true });
3,267✔
1377
            return this.destMap.get(
3,267✔
1378
                standardizedPath
1379
            ) as T;
1380
        }
1381
    }
1382

1383
    private sortedScopeNames: string[] = undefined;
1,985✔
1384

1385
    /**
1386
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1387
     */
1388
    private getSortedScopeNames() {
1389
        if (!this.sortedScopeNames) {
13,302✔
1390
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,471✔
1391
                if (a === 'global') {
2,123!
UNCOV
1392
                    return -1;
×
1393
                } else if (b === 'global') {
2,123✔
1394
                    return 1;
1,434✔
1395
                }
1396
                if (a === 'source') {
689✔
1397
                    return -1;
30✔
1398
                } else if (b === 'source') {
659✔
1399
                    return 1;
152✔
1400
                }
1401
                if (a < b) {
507✔
1402
                    return -1;
203✔
1403
                } else if (b < a) {
304!
1404
                    return 1;
304✔
1405
                }
UNCOV
1406
                return 0;
×
1407
            });
1408
        }
1409
        return this.sortedScopeNames;
13,302✔
1410
    }
1411

1412
    /**
1413
     * Get a list of all scopes the file is loaded into
1414
     * @param file the file
1415
     */
1416
    public getScopesForFile(file: BscFile | string) {
1417
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,335✔
1418

1419
        let result = [] as Scope[];
4,335✔
1420
        if (resolvedFile) {
4,335✔
1421
            const scopeKeys = this.getSortedScopeNames();
4,334✔
1422
            for (let key of scopeKeys) {
4,334✔
1423
                let scope = this.scopes[key];
40,112✔
1424

1425
                if (scope.hasFile(resolvedFile)) {
40,112✔
1426
                    result.push(scope);
4,903✔
1427
                }
1428
            }
1429
        }
1430
        return result;
4,335✔
1431
    }
1432

1433
    /**
1434
     * Get the first found scope for a file.
1435
     */
1436
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1437
        const scopeKeys = this.getSortedScopeNames();
4,347✔
1438
        for (let key of scopeKeys) {
4,347✔
1439
            let scope = this.scopes[key];
19,150✔
1440

1441
            if (scope.hasFile(file)) {
19,150✔
1442
                return scope;
3,164✔
1443
            }
1444
        }
1445
    }
1446

1447
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1448
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1449
        const filesSearched = new Set<BrsFile>();
39✔
1450
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1451
        let lowerName = name?.toLowerCase();
39!
1452

1453
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1454
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1455
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1456
                if (!results.has(statement)) {
36!
1457
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1458
                }
1459
            }
1460
        }
1461

1462
        //look through all files in scope for matches
1463
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1464
            for (const file of scope.getAllFiles()) {
39✔
1465
                //skip non-brs files, or files we've already processed
1466
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1467
                    continue;
3✔
1468
                }
1469
                filesSearched.add(file);
42✔
1470

1471
                file.ast.walk(createVisitor({
42✔
1472
                    FunctionStatement: (statement: FunctionStatement) => {
1473
                        addToResults(statement, file);
95✔
1474
                    },
1475
                    MethodStatement: (statement: MethodStatement) => {
1476
                        addToResults(statement, file);
3✔
1477
                    }
1478
                }), {
1479
                    walkMode: WalkMode.visitStatements
1480
                });
1481
            }
1482
        }
1483
        return [...results.values()];
39✔
1484
    }
1485

1486
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1487
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1488
        const filesSearched = new Set<BrsFile>();
14✔
1489

1490
        //get all function names for the xml file and parents
1491
        let funcNames = new Set<string>();
14✔
1492
        let currentScope = scope;
14✔
1493
        while (isXmlScope(currentScope)) {
14✔
1494
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1495
                if (!filterName || name === filterName) {
20!
1496
                    funcNames.add(name);
20✔
1497
                }
1498
            }
1499
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1500
        }
1501

1502
        //look through all files in scope for matches
1503
        for (const file of scope.getOwnFiles()) {
14✔
1504
            //skip non-brs files, or files we've already processed
1505
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1506
                continue;
14✔
1507
            }
1508
            filesSearched.add(file);
14✔
1509

1510
            file.ast.walk(createVisitor({
14✔
1511
                FunctionStatement: (statement: FunctionStatement) => {
1512
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1513
                        if (!results.has(statement)) {
19!
1514
                            results.set(statement, { item: statement, file: file });
19✔
1515
                        }
1516
                    }
1517
                }
1518
            }), {
1519
                walkMode: WalkMode.visitStatements
1520
            });
1521
        }
1522
        return [...results.values()];
14✔
1523
    }
1524

1525
    /**
1526
     * Find all available completion items at the given position
1527
     * @param filePath can be a srcPath or a destPath
1528
     * @param position the position (line & column) where completions should be found
1529
     */
1530
    public getCompletions(filePath: string, position: Position) {
1531
        let file = this.getFile(filePath);
126✔
1532
        if (!file) {
126!
UNCOV
1533
            return [];
×
1534
        }
1535

1536
        const event: ProvideCompletionsEvent = {
126✔
1537
            program: this,
1538
            file: file,
1539
            scopes: this.getScopesForFile(file),
1540
            position: position,
1541
            completions: []
1542
        };
1543

1544
        this.plugins.emit('beforeProvideCompletions', event);
126✔
1545

1546
        this.plugins.emit('provideCompletions', event);
126✔
1547

1548
        this.plugins.emit('afterProvideCompletions', event);
126✔
1549

1550
        return event.completions;
126✔
1551
    }
1552

1553
    /**
1554
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1555
     */
1556
    public getWorkspaceSymbols() {
1557
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1558
            program: this,
1559
            workspaceSymbols: []
1560
        };
1561
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1562
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1563
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1564
        return event.workspaceSymbols;
22✔
1565
    }
1566

1567
    /**
1568
     * Given a position in a file, if the position is sitting on some type of identifier,
1569
     * go to the definition of that identifier (where this thing was first defined)
1570
     */
1571
    public getDefinition(srcPath: string, position: Position): Location[] {
1572
        let file = this.getFile(srcPath);
18✔
1573
        if (!file) {
18!
UNCOV
1574
            return [];
×
1575
        }
1576

1577
        const event: ProvideDefinitionEvent = {
18✔
1578
            program: this,
1579
            file: file,
1580
            position: position,
1581
            definitions: []
1582
        };
1583

1584
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1585
        this.plugins.emit('provideDefinition', event);
18✔
1586
        this.plugins.emit('afterProvideDefinition', event);
18✔
1587
        return event.definitions;
18✔
1588
    }
1589

1590
    /**
1591
     * Get hover information for a file and position
1592
     */
1593
    public getHover(srcPath: string, position: Position): Hover[] {
1594
        let file = this.getFile(srcPath);
73✔
1595
        let result: Hover[];
1596
        if (file) {
73!
1597
            const event = {
73✔
1598
                program: this,
1599
                file: file,
1600
                position: position,
1601
                scopes: this.getScopesForFile(file),
1602
                hovers: []
1603
            } as ProvideHoverEvent;
1604
            this.plugins.emit('beforeProvideHover', event);
73✔
1605
            this.plugins.emit('provideHover', event);
73✔
1606
            this.plugins.emit('afterProvideHover', event);
73✔
1607
            result = event.hovers;
73✔
1608
        }
1609

1610
        return result ?? [];
73!
1611
    }
1612

1613
    /**
1614
     * Get full list of document symbols for a file
1615
     * @param srcPath path to the file
1616
     */
1617
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1618
        let file = this.getFile(srcPath);
24✔
1619
        if (file) {
24!
1620
            const event: ProvideDocumentSymbolsEvent = {
24✔
1621
                program: this,
1622
                file: file,
1623
                documentSymbols: []
1624
            };
1625
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1626
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1627
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1628
            return event.documentSymbols;
24✔
1629
        } else {
UNCOV
1630
            return undefined;
×
1631
        }
1632
    }
1633

1634
    /**
1635
     * Compute code actions for the given file and range
1636
     */
1637
    public getCodeActions(srcPath: string, range: Range) {
1638
        const codeActions = [] as CodeAction[];
16✔
1639
        const file = this.getFile(srcPath);
16✔
1640
        if (file) {
16✔
1641
            const fileUri = util.pathToUri(file?.srcPath);
15!
1642
            const diagnostics = this
15✔
1643
                //get all current diagnostics (filtered by diagnostic filters)
1644
                .getDiagnostics()
1645
                //only keep diagnostics related to this file
1646
                .filter(x => x.location?.uri === fileUri)
28!
1647
                //only keep diagnostics that touch this range
1648
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
20✔
1649

1650
            const scopes = this.getScopesForFile(file);
15✔
1651

1652
            this.plugins.emit('onGetCodeActions', {
15✔
1653
                program: this,
1654
                file: file,
1655
                range: range,
1656
                diagnostics: diagnostics,
1657
                scopes: scopes,
1658
                codeActions: codeActions
1659
            });
1660
        }
1661
        return codeActions;
16✔
1662
    }
1663

1664
    /**
1665
     * Get semantic tokens for the specified file
1666
     */
1667
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1668
        const file = this.getFile(srcPath);
25✔
1669
        if (file) {
25!
1670
            const result = [] as SemanticToken[];
25✔
1671
            this.plugins.emit('onGetSemanticTokens', {
25✔
1672
                program: this,
1673
                file: file,
1674
                scopes: this.getScopesForFile(file),
1675
                semanticTokens: result
1676
            });
1677
            return result;
25✔
1678
        }
1679
    }
1680

1681
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1682
        let file: BrsFile = this.getFile(filePath);
185✔
1683
        if (!file || !isBrsFile(file)) {
185✔
1684
            return [];
3✔
1685
        }
1686
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1687
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1688
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1689
    }
1690

1691
    public getReferences(srcPath: string, position: Position): Location[] {
1692
        //find the file
1693
        let file = this.getFile(srcPath);
4✔
1694

1695
        const event: ProvideReferencesEvent = {
4✔
1696
            program: this,
1697
            file: file,
1698
            position: position,
1699
            references: []
1700
        };
1701

1702
        this.plugins.emit('beforeProvideReferences', event);
4✔
1703
        this.plugins.emit('provideReferences', event);
4✔
1704
        this.plugins.emit('afterProvideReferences', event);
4✔
1705

1706
        return event.references;
4✔
1707
    }
1708

1709
    /**
1710
     * Transpile a single file and get the result as a string.
1711
     * This does not write anything to the file system.
1712
     *
1713
     * This should only be called by `LanguageServer`.
1714
     * Internal usage should call `_getTranspiledFileContents` instead.
1715
     * @param filePath can be a srcPath or a destPath
1716
     */
1717
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1718
        const file = this.getFile(filePath);
332✔
1719

1720
        return this.getTranspiledFileContentsPipeline.run(async () => {
332✔
1721

1722
            const result = {
332✔
1723
                destPath: file.destPath,
1724
                pkgPath: file.pkgPath,
1725
                srcPath: file.srcPath
1726
            } as FileTranspileResult;
1727

1728
            const expectedPkgPath = file.pkgPath.toLowerCase();
332✔
1729
            const expectedMapPath = `${expectedPkgPath}.map`;
332✔
1730
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
332✔
1731

1732
            //add a temporary plugin to tap into the file writing process
1733
            const plugin = this.plugins.addFirst({
332✔
1734
                name: 'getTranspiledFileContents',
1735
                beforeWriteFile: (event) => {
1736
                    const pkgPath = event.file.pkgPath.toLowerCase();
1,034✔
1737
                    switch (pkgPath) {
1,034✔
1738
                        //this is the actual transpiled file
1739
                        case expectedPkgPath:
1,034✔
1740
                            result.code = event.file.data.toString();
332✔
1741
                            break;
332✔
1742
                        //this is the sourcemap
1743
                        case expectedMapPath:
1744
                            result.map = event.file.data.toString();
177✔
1745
                            break;
177✔
1746
                        //this is the typedef
1747
                        case expectedTypedefPkgPath:
1748
                            result.typedef = event.file.data.toString();
8✔
1749
                            break;
8✔
1750
                        default:
1751
                        //no idea what this file is. just ignore it
1752
                    }
1753
                    //mark every file as processed so it they don't get written to the output directory
1754
                    event.processedFiles.add(event.file);
1,034✔
1755
                }
1756
            });
1757

1758
            try {
332✔
1759
                //now that the plugin has been registered, run the build with just this file
1760
                await this.build({
332✔
1761
                    files: [file]
1762
                });
1763
            } finally {
1764
                this.plugins.remove(plugin);
332✔
1765
            }
1766
            return result;
332✔
1767
        });
1768
    }
1769
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,985✔
1770

1771
    /**
1772
     * Get the absolute output path for a file
1773
     */
1774
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1775
        return s`${stagingDir}/${file.pkgPath}`;
1,901✔
1776
    }
1777

1778
    private getStagingDir(stagingDir?: string) {
1779
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
745✔
1780
        if (!result) {
745✔
1781
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
557✔
1782
        }
1783
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
745!
1784
        return result;
745✔
1785
    }
1786

1787
    /**
1788
     * Prepare the program for building
1789
     * @param files the list of files that should be prepared
1790
     */
1791
    private async prepare(files: BscFile[]) {
1792
        const programEvent: PrepareProgramEvent = {
373✔
1793
            program: this,
1794
            editor: this.editor,
1795
            files: files
1796
        };
1797

1798
        //assign an editor to every file
1799
        for (const file of programEvent.files) {
373✔
1800
            //if the file doesn't have an editor yet, assign one now
1801
            if (!file.editor) {
756✔
1802
                file.editor = new Editor();
709✔
1803
            }
1804
        }
1805

1806
        //sort the entries to make transpiling more deterministic
1807
        programEvent.files.sort((a, b) => {
373✔
1808
            if (a.pkgPath < b.pkgPath) {
399✔
1809
                return -1;
338✔
1810
            } else if (a.pkgPath > b.pkgPath) {
61!
1811
                return 1;
61✔
1812
            } else {
UNCOV
1813
                return 1;
×
1814
            }
1815
        });
1816

1817
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
373✔
1818
        await this.plugins.emitAsync('prepareProgram', programEvent);
373✔
1819

1820
        const stagingDir = this.getStagingDir();
373✔
1821

1822
        const entries: TranspileObj[] = [];
373✔
1823

1824
        for (const file of files) {
373✔
1825
            const scope = this.getFirstScopeForFile(file);
756✔
1826
            //link the symbol table for all the files in this scope
1827
            scope?.linkSymbolTable();
756✔
1828

1829
            //if the file doesn't have an editor yet, assign one now
1830
            if (!file.editor) {
756!
UNCOV
1831
                file.editor = new Editor();
×
1832
            }
1833
            const event = {
756✔
1834
                program: this,
1835
                file: file,
1836
                editor: file.editor,
1837
                scope: scope,
1838
                outputPath: this.getOutputPath(file, stagingDir)
1839
            } as PrepareFileEvent & { outputPath: string };
1840

1841
            await this.plugins.emitAsync('beforePrepareFile', event);
756✔
1842
            await this.plugins.emitAsync('prepareFile', event);
756✔
1843
            await this.plugins.emitAsync('afterPrepareFile', event);
756✔
1844

1845
            //TODO remove this in v1
1846
            entries.push(event);
756✔
1847

1848
            //unlink the symbolTable so the next loop iteration can link theirs
1849
            scope?.unlinkSymbolTable();
756✔
1850
        }
1851

1852
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
373✔
1853
        return files;
373✔
1854
    }
1855

1856
    /**
1857
     * Generate the contents of every file
1858
     */
1859
    private async serialize(files: BscFile[]) {
1860

1861
        const allFiles = new Map<BscFile, SerializedFile[]>();
372✔
1862

1863
        //exclude prunable files if that option is enabled
1864
        if (this.options.pruneEmptyCodeFiles === true) {
372✔
1865
            files = files.filter(x => x.canBePruned !== true);
9✔
1866
        }
1867

1868
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
372✔
1869
            program: this,
1870
            files: files,
1871
            result: allFiles
1872
        });
1873
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
372✔
1874

1875
        // serialize each file
1876
        for (const file of files) {
372✔
1877
            let scope = this.getFirstScopeForFile(file);
753✔
1878

1879
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1880
            if (!scope) {
753✔
1881
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
383✔
1882
                scope.getAllFiles = () => [file];
3,434✔
1883
                scope.getOwnFiles = scope.getAllFiles;
383✔
1884
            }
1885

1886
            //link the symbol table for all the files in this scope
1887
            scope?.linkSymbolTable();
753!
1888
            const event: SerializeFileEvent = {
753✔
1889
                program: this,
1890
                file: file,
1891
                scope: scope,
1892
                result: allFiles
1893
            };
1894
            await this.plugins.emitAsync('beforeSerializeFile', event);
753✔
1895
            await this.plugins.emitAsync('serializeFile', event);
753✔
1896
            await this.plugins.emitAsync('afterSerializeFile', event);
753✔
1897
            //unlink the symbolTable so the next loop iteration can link theirs
1898
            scope?.unlinkSymbolTable();
753!
1899
        }
1900

1901
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
372✔
1902

1903
        return allFiles;
372✔
1904
    }
1905

1906
    /**
1907
     * Write the entire project to disk
1908
     */
1909
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1910
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
372✔
1911
            program: this,
1912
            files: files,
1913
            stagingDir: stagingDir
1914
        });
1915
        //empty the staging directory
1916
        await fsExtra.emptyDir(stagingDir);
372✔
1917

1918
        const serializedFiles = [...files]
372✔
1919
            .map(([, serializedFiles]) => serializedFiles)
753✔
1920
            .flat();
1921

1922
        //write all the files to disk (asynchronously)
1923
        await Promise.all(
372✔
1924
            serializedFiles.map(async (file) => {
1925
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,145✔
1926
                    program: this,
1927
                    file: file,
1928
                    outputPath: this.getOutputPath(file, stagingDir),
1929
                    processedFiles: new Set<SerializedFile>()
1930
                });
1931

1932
                await this.plugins.emitAsync('writeFile', event);
1,145✔
1933

1934
                await this.plugins.emitAsync('afterWriteFile', event);
1,145✔
1935
            })
1936
        );
1937

1938
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
372✔
1939
    }
1940

1941
    private buildPipeline = new ActionPipeline();
1,985✔
1942

1943
    /**
1944
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1945
     * @param options the list of options used to build the program
1946
     */
1947
    public async build(options?: ProgramBuildOptions) {
1948
        //run a single build at a time
1949
        await this.buildPipeline.run(async () => {
372✔
1950
            const stagingDir = this.getStagingDir(options?.stagingDir);
372✔
1951

1952
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
372✔
1953
                program: this,
1954
                editor: this.editor,
1955
                files: options?.files ?? Object.values(this.files)
2,232✔
1956
            });
1957

1958
            //prepare the program (and files) for building
1959
            event.files = await this.prepare(event.files);
372✔
1960

1961
            //stage the entire program
1962
            const serializedFilesByFile = await this.serialize(event.files);
372✔
1963

1964
            await this.write(stagingDir, serializedFilesByFile);
372✔
1965

1966
            await this.plugins.emitAsync('afterBuildProgram', event);
372✔
1967

1968
            //undo all edits for the program
1969
            this.editor.undoAll();
372✔
1970
            //undo all edits for each file
1971
            for (const file of event.files) {
372✔
1972
                file.editor.undoAll();
754✔
1973
            }
1974
        });
1975

1976
        this.logger.debug('Types Created', TypesCreated);
372✔
1977
        let totalTypesCreated = 0;
372✔
1978
        for (const key in TypesCreated) {
372✔
1979
            if (TypesCreated.hasOwnProperty(key)) {
9,664!
1980
                totalTypesCreated += TypesCreated[key];
9,664✔
1981

1982
            }
1983
        }
1984
        this.logger.info('Total Types Created', totalTypesCreated);
372✔
1985
    }
1986

1987
    /**
1988
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1989
     */
1990
    public findFilesForFunction(functionName: string) {
1991
        const files = [] as BscFile[];
7✔
1992
        const lowerFunctionName = functionName.toLowerCase();
7✔
1993
        //find every file with this function defined
1994
        for (const file of Object.values(this.files)) {
7✔
1995
            if (isBrsFile(file)) {
25✔
1996
                //TODO handle namespace-relative function calls
1997
                //if the file has a function with this name
1998
                // eslint-disable-next-line @typescript-eslint/dot-notation
1999
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
2000
                    files.push(file);
2✔
2001
                }
2002
            }
2003
        }
2004
        return files;
7✔
2005
    }
2006

2007
    /**
2008
     * Find a list of files in the program that have a class with the given name (case INsensitive)
2009
     */
2010
    public findFilesForClass(className: string) {
2011
        const files = [] as BscFile[];
7✔
2012
        const lowerClassName = className.toLowerCase();
7✔
2013
        //find every file with this class defined
2014
        for (const file of Object.values(this.files)) {
7✔
2015
            if (isBrsFile(file)) {
25✔
2016
                //TODO handle namespace-relative classes
2017
                //if the file has a function with this name
2018

2019
                // eslint-disable-next-line @typescript-eslint/dot-notation
2020
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
2021
                    files.push(file);
1✔
2022
                }
2023
            }
2024
        }
2025
        return files;
7✔
2026
    }
2027

2028
    public findFilesForNamespace(name: string) {
2029
        const files = [] as BscFile[];
7✔
2030
        const lowerName = name.toLowerCase();
7✔
2031
        //find every file with this class defined
2032
        for (const file of Object.values(this.files)) {
7✔
2033
            if (isBrsFile(file)) {
25✔
2034

2035
                // eslint-disable-next-line @typescript-eslint/dot-notation
2036
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
2037
                    const namespaceName = x.name.toLowerCase();
7✔
2038
                    return (
7✔
2039
                        //the namespace name matches exactly
2040
                        namespaceName === lowerName ||
9✔
2041
                        //the full namespace starts with the name (honoring the part boundary)
2042
                        namespaceName.startsWith(lowerName + '.')
2043
                    );
2044
                })) {
2045
                    files.push(file);
6✔
2046
                }
2047
            }
2048
        }
2049

2050
        return files;
7✔
2051
    }
2052

2053
    public findFilesForEnum(name: string) {
2054
        const files = [] as BscFile[];
8✔
2055
        const lowerName = name.toLowerCase();
8✔
2056
        //find every file with this enum defined
2057
        for (const file of Object.values(this.files)) {
8✔
2058
            if (isBrsFile(file)) {
26✔
2059
                // eslint-disable-next-line @typescript-eslint/dot-notation
2060
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
2061
                    files.push(file);
1✔
2062
                }
2063
            }
2064
        }
2065
        return files;
8✔
2066
    }
2067

2068
    private _manifest: Map<string, string>;
2069

2070
    /**
2071
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
2072
     * @param parsedManifest The manifest map to read from and modify
2073
     */
2074
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
2075
        // Lift the bs_consts defined in the manifest
2076
        let bsConsts = getBsConst(parsedManifest, false);
17✔
2077

2078
        // Override or delete any bs_consts defined in the bs config
2079
        for (const key in this.options?.manifest?.bs_const) {
17!
2080
            const value = this.options.manifest.bs_const[key];
3✔
2081
            if (value === null) {
3✔
2082
                bsConsts.delete(key);
1✔
2083
            } else {
2084
                bsConsts.set(key, value);
2✔
2085
            }
2086
        }
2087

2088
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
2089
        let constString = '';
17✔
2090
        for (const [key, value] of bsConsts) {
17✔
2091
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
2092
        }
2093

2094
        // Set the updated bs_const value
2095
        parsedManifest.set('bs_const', constString);
17✔
2096
    }
2097

2098
    /**
2099
     * Try to find and load the manifest into memory
2100
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
2101
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
2102
     */
2103
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,617✔
2104
        //if we already have a manifest instance, and should not replace...then don't replace
2105
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,625!
UNCOV
2106
            return;
×
2107
        }
2108
        let manifestPath = manifestFileObj
1,625✔
2109
            ? manifestFileObj.src
1,625✔
2110
            : path.join(this.options.rootDir, 'manifest');
2111

2112
        try {
1,625✔
2113
            // we only load this manifest once, so do it sync to improve speed downstream
2114
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,625✔
2115
            const parsedManifest = parseManifest(contents);
17✔
2116
            this.buildBsConstsIntoParsedManifest(parsedManifest);
17✔
2117
            this._manifest = parsedManifest;
17✔
2118
        } catch (e) {
2119
            this._manifest = new Map();
1,608✔
2120
        }
2121
    }
2122

2123
    /**
2124
     * Get a map of the manifest information
2125
     */
2126
    public getManifest() {
2127
        if (!this._manifest) {
2,584✔
2128
            this.loadManifest();
1,616✔
2129
        }
2130
        return this._manifest;
2,584✔
2131
    }
2132

2133
    public dispose() {
2134
        this.plugins.emit('beforeProgramDispose', { program: this });
1,848✔
2135

2136
        for (let filePath in this.files) {
1,848✔
2137
            this.files[filePath]?.dispose?.();
2,337!
2138
        }
2139
        for (let name in this.scopes) {
1,848✔
2140
            this.scopes[name]?.dispose?.();
3,891!
2141
        }
2142
        this.globalScope?.dispose?.();
1,848!
2143
        this.dependencyGraph?.dispose?.();
1,848!
2144
    }
2145
}
2146

2147
export interface FileTranspileResult {
2148
    srcPath: string;
2149
    destPath: string;
2150
    pkgPath: string;
2151
    code: string;
2152
    map: string;
2153
    typedef: string;
2154
}
2155

2156

2157
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2158
    constructor(
2159
        public program: Program,
2,659✔
2160
        public srcPath: string,
2,659✔
2161
        public destPath: string,
2,659✔
2162
        public data: LazyFileData,
2,659✔
2163
        public fileFactory: FileFactory
2,659✔
2164
    ) {
2165
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,659!
2166
    }
2167

2168
    public srcExtension: string;
2169

2170
    public files: TFile[] = [];
2,659✔
2171
}
2172

2173
export interface ProgramBuildOptions {
2174
    /**
2175
     * The directory where the final built files should be placed. This directory will be cleared before running
2176
     */
2177
    stagingDir?: string;
2178
    /**
2179
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2180
     * Typically you will want to leave this blank
2181
     */
2182
    files?: BscFile[];
2183
}
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