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

rokucommunity / brighterscript / #13384

29 Nov 2024 06:38PM UTC coverage: 86.828% (-1.3%) from 88.168%
#13384

push

web-flow
Merge 346e2057f into 57fa2ad4d

12078 of 14711 branches covered (82.1%)

Branch coverage included in aggregate %.

378 of 406 new or added lines in 36 files covered. (93.1%)

817 existing lines in 45 files now uncovered.

13071 of 14253 relevant lines covered (91.71%)

33086.62 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

131

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

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

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

179
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
809,640✔
180

181
        for (const callable of globalCallables) {
1,819✔
182
            this.globalScope.symbolTable.addSymbol(callable.name, { description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
141,882✔
183
        }
184

185
        for (const ifaceData of Object.values(interfaces) as BRSInterfaceData[]) {
1,819✔
186
            const nodeType = new InterfaceType(ifaceData.name);
160,072✔
187
            nodeType.addBuiltInInterfaces();
160,072✔
188
            nodeType.isBuiltIn = true;
160,072✔
189
            this.globalScope.symbolTable.addSymbol(ifaceData.name, { description: ifaceData.description }, nodeType, SymbolTypeFlag.typetime);
160,072✔
190
        }
191

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

202
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,819✔
203
            this.recursivelyAddNodeToSymbolTable(nodeData);
174,624✔
204
        }
205

206
        for (const eventData of Object.values(events) as BRSEventData[]) {
1,819✔
207
            const nodeType = new InterfaceType(eventData.name);
32,742✔
208
            nodeType.addBuiltInInterfaces();
32,742✔
209
            nodeType.isBuiltIn = true;
32,742✔
210
            this.globalScope.symbolTable.addSymbol(eventData.name, { description: eventData.description }, nodeType, SymbolTypeFlag.typetime);
32,742✔
211
        }
212

213
    }
214

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

223
    public diagnostics: DiagnosticManager;
224

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

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

236
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
1,819✔
237

238
    private currentScopeValidationOptions: ScopeValidationOptions;
239

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

245

246
    private componentsTable = new SymbolTable('Custom Components');
1,819✔
247

248
    public addFileSymbolInfo(file: BrsFile) {
249
        this.fileSymbolInformation.set(file.pkgPath, {
1,698✔
250
            provides: file.providedSymbols,
251
            requires: file.requiredSymbols
252
        });
253
    }
254

255
    public getFileSymbolInfo(file: BrsFile) {
256
        return this.fileSymbolInformation.get(file.pkgPath);
1,701✔
257
    }
258

259
    /**
260
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
261
     */
262
    public get bslibPkgPath() {
263
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
264
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
2,459✔
265
            return bslibAliasedRokuModulesPkgPath;
11✔
266

267
            //if there's a non-aliased version of bslib from roku_modules, use that
268
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
2,448✔
269
            return bslibNonAliasedRokuModulesPkgPath;
24✔
270

271
            //default to the embedded version
272
        } else {
273
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
2,424✔
274
        }
275
    }
276

277
    public get bslibPrefix() {
278
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
1,792✔
279
            return 'rokucommunity_bslib';
18✔
280
        } else {
281
            return 'bslib';
1,774✔
282
        }
283
    }
284

285

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

301
    private scopes = {} as Record<string, Scope>;
1,819✔
302

303
    protected addScope(scope: Scope) {
304
        this.scopes[scope.name] = scope;
1,989✔
305
        delete this.sortedScopeNames;
1,989✔
306
    }
307

308
    protected removeScope(scope: Scope) {
309
        if (this.scopes[scope.name]) {
15!
310
            delete this.scopes[scope.name];
15✔
311
            delete this.sortedScopeNames;
15✔
312
        }
313
    }
314

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

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

336
    /**
337
     * Get the sorted names of custom components
338
     */
339
    public getSortedComponentNames() {
340
        const componentNames = Object.keys(this.components);
1,365✔
341
        componentNames.sort((a, b) => {
1,365✔
342
            if (a < b) {
712✔
343
                return -1;
286✔
344
            } else if (b < a) {
426!
345
                return 1;
426✔
346
            }
UNCOV
347
            return 0;
×
348
        });
349
        return componentNames;
1,365✔
350
    }
351

352
    /**
353
     * Keeps a set of all the components that need to have their types updated during the current validation cycle
354
     * Map <componentKey, componentName>
355
     */
356
    private componentSymbolsToUpdate = new Map<string, string>();
1,819✔
357

358
    /**
359
     * Register (or replace) the reference to a component in the component map
360
     */
361
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
362
        const key = this.getComponentKey(xmlFile);
414✔
363
        if (!this.components[key]) {
414✔
364
            this.components[key] = [];
398✔
365
        }
366
        this.components[key].push({
414✔
367
            file: xmlFile,
368
            scope: scope
369
        });
370
        this.components[key].sort((a, b) => {
414✔
371
            const pathA = a.file.destPath.toLowerCase();
5✔
372
            const pathB = b.file.destPath.toLowerCase();
5✔
373
            if (pathA < pathB) {
5✔
374
                return -1;
1✔
375
            } else if (pathA > pathB) {
4!
376
                return 1;
4✔
377
            }
UNCOV
378
            return 0;
×
379
        });
380
        this.syncComponentDependencyGraph(this.components[key]);
414✔
381
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
414✔
382
    }
383

384
    /**
385
     * Remove the specified component from the components map
386
     */
387
    private unregisterComponent(xmlFile: XmlFile) {
388
        const key = this.getComponentKey(xmlFile);
15✔
389
        const arr = this.components[key] || [];
15!
390
        for (let i = 0; i < arr.length; i++) {
15✔
391
            if (arr[i].file === xmlFile) {
15!
392
                arr.splice(i, 1);
15✔
393
                break;
15✔
394
            }
395
        }
396

397
        this.syncComponentDependencyGraph(arr);
15✔
398
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
15✔
399
    }
400

401
    /**
402
     * Adds a component described in an XML to the set of components that needs to be updated this validation cycle.
403
     * @param xmlFile XML file with <component> tag
404
     */
405
    private addDeferredComponentTypeSymbolCreation(xmlFile: XmlFile) {
406
        const componentKey = this.getComponentKey(xmlFile);
1,374✔
407
        const componentName = xmlFile.componentName?.text;
1,374✔
408
        if (this.componentSymbolsToUpdate.has(componentKey)) {
1,374✔
409
            return;
835✔
410
        }
411
        this.componentSymbolsToUpdate.set(componentKey, componentName);
539✔
412
    }
413

414
    private getComponentKey(xmlFile: XmlFile) {
415
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,803✔
416
    }
417

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

436
            this.componentsTable.removeSymbol(symbolName);
464✔
437
            componentScope.linkSymbolTable();
464✔
438
            const componentType = componentScope.getComponentType();
464✔
439
            if (componentType) {
464!
440
                this.componentsTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
464✔
441
            }
442
            const typeData = {};
464✔
443
            const isSameAsPrevious = previousComponentType && componentType.isEqual(previousComponentType, typeData);
464✔
444
            const isComponentTypeDifferent = !previousComponentType || isReferenceType(previousComponentType) || !isSameAsPrevious;
464✔
445
            componentScope.unlinkSymbolTable();
464✔
446
            return isComponentTypeDifferent;
464✔
447

448
        }
449
        // There was a previous component type, but no new one, so it's different
450
        return !!previousComponentType;
1✔
451
    }
452

453
    /**
454
     * 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
455
     * @param componentKey key getting a component from `this.components`
456
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
457
     */
458
    private addComponentReferenceType(componentKey: string, componentName: string) {
459
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
472✔
460
        if (!symbolName) {
472✔
461
            return;
7✔
462
        }
463
        const components = this.components[componentKey] || [];
465!
464

465
        if (components.length > 0) {
465✔
466
            // There is a component that can be added,
467
            if (!this.componentsTable.hasSymbol(symbolName, SymbolTypeFlag.typetime)) {
464✔
468
                // it doesn't already exist in the table
469
                const componentRefType = new ReferenceType(symbolName, symbolName, SymbolTypeFlag.typetime, () => this.componentsTable);
2,532✔
470
                if (componentRefType) {
325!
471
                    this.componentsTable.addSymbol(symbolName, {}, componentRefType, SymbolTypeFlag.typetime);
325✔
472
                }
473
            }
474
        } else {
475
            // there is no component. remove from table
476
            this.componentsTable.removeSymbol(symbolName);
1✔
477
        }
478
    }
479

480
    /**
481
     * re-attach the dependency graph with a new key for any component who changed
482
     * their position in their own named array (only matters when there are multiple
483
     * components with the same name)
484
     */
485
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
486
        //reattach every dependency graph
487
        for (let i = 0; i < components.length; i++) {
429✔
488
            const { file, scope } = components[i];
420✔
489

490
            //attach (or re-attach) the dependencyGraph for every component whose position changed
491
            if (file.dependencyGraphIndex !== i) {
420✔
492
                file.dependencyGraphIndex = i;
416✔
493
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
416✔
494
                file.attachDependencyGraph(this.dependencyGraph);
416✔
495
                scope.attachDependencyGraph(this.dependencyGraph);
416✔
496
            }
497
        }
498
    }
499

500
    /**
501
     * Get a list of all files that are included in the project but are not referenced
502
     * by any scope in the program.
503
     */
504
    public getUnreferencedFiles() {
UNCOV
505
        let result = [] as BscFile[];
×
UNCOV
506
        for (let filePath in this.files) {
×
UNCOV
507
            let file = this.files[filePath];
×
508
            //is this file part of a scope
UNCOV
509
            if (!this.getFirstScopeForFile(file)) {
×
510
                //no scopes reference this file. add it to the list
UNCOV
511
                result.push(file);
×
512
            }
513
        }
UNCOV
514
        return result;
×
515
    }
516

517
    /**
518
     * Get the list of errors for the entire program.
519
     */
520
    public getDiagnostics() {
521
        return this.diagnostics.getDiagnostics();
1,162✔
522
    }
523

524
    /**
525
     * Determine if the specified file is loaded in this program right now.
526
     * @param filePath the absolute or relative path to the file
527
     * @param normalizePath should the provided path be normalized before use
528
     */
529
    public hasFile(filePath: string, normalizePath = true) {
2,607✔
530
        return !!this.getFile(filePath, normalizePath);
2,607✔
531
    }
532

533
    /**
534
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
535
     * @param scopeName xml scope names are their `destPath`. Source scope is stored with the key `"source"`
536
     */
537
    public getScopeByName(scopeName: string): Scope | undefined {
538
        if (!scopeName) {
60!
UNCOV
539
            return undefined;
×
540
        }
541
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
542
        //so it's safe to run the standardizePkgPath method
543
        scopeName = s`${scopeName}`;
60✔
544
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
137✔
545
        return this.scopes[key!];
60✔
546
    }
547

548
    /**
549
     * Return all scopes
550
     */
551
    public getScopes() {
552
        return Object.values(this.scopes);
10✔
553
    }
554

555
    /**
556
     * Find the scope for the specified component
557
     */
558
    public getComponentScope(componentName: string) {
559
        return this.getComponent(componentName)?.scope;
879✔
560
    }
561

562
    /**
563
     * Update internal maps with this file reference
564
     */
565
    private assignFile<T extends BscFile = BscFile>(file: T) {
566
        const fileAddEvent: BeforeFileAddEvent = {
2,420✔
567
            file: file,
568
            program: this
569
        };
570

571
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,420✔
572

573
        this.files[file.srcPath.toLowerCase()] = file;
2,420✔
574
        this.destMap.set(file.destPath.toLowerCase(), file);
2,420✔
575

576
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,420✔
577

578
        return file;
2,420✔
579
    }
580

581
    /**
582
     * Remove this file from internal maps
583
     */
584
    private unassignFile<T extends BscFile = BscFile>(file: T) {
585
        delete this.files[file.srcPath.toLowerCase()];
160✔
586
        this.destMap.delete(file.destPath.toLowerCase());
160✔
587
        return file;
160✔
588
    }
589

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

607
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
2,416✔
608
            //if the file is already loaded, remove it
609
            if (this.hasFile(srcPath)) {
2,416✔
610
                this.removeFile(srcPath, true, true);
144✔
611
            }
612

613
            const data = new LazyFileData(fileData);
2,416✔
614

615
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,416✔
616

617
            this.plugins.emit('beforeProvideFile', event);
2,416✔
618
            this.plugins.emit('provideFile', event);
2,416✔
619
            this.plugins.emit('afterProvideFile', event);
2,416✔
620

621
            //if no files were provided, create a AssetFile to represent it.
622
            if (event.files.length === 0) {
2,416✔
623
                event.files.push(
18✔
624
                    this.fileFactory.AssetFile({
625
                        srcPath: event.srcPath,
626
                        destPath: event.destPath,
627
                        pkgPath: event.destPath,
628
                        data: data
629
                    })
630
                );
631
            }
632

633
            //find the file instance for the srcPath that triggered this action.
634
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
2,416✔
635

636
            if (!primaryFile) {
2,416!
637
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
638
                    type: x.type,
639
                    srcPath: x.srcPath,
640
                    destPath: x.destPath
641
                })))}`);
642
            }
643

644
            //link the virtual files to the primary file
645
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
2,416!
646

647
            for (const file of event.files) {
2,416✔
648
                file.srcPath = s(file.srcPath);
2,420✔
649
                if (file.destPath) {
2,420!
650
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
2,420✔
651
                }
652
                if (file.pkgPath) {
2,420✔
653
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
2,416✔
654
                } else {
655
                    file.pkgPath = file.destPath;
4✔
656
                }
657
                file.excludeFromOutput = file.excludeFromOutput === true;
2,420✔
658

659
                //set the dependencyGraph key for every file to its destPath
660
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,420✔
661

662
                this.assignFile(file);
2,420✔
663

664
                //register a callback anytime this file's dependencies change
665
                if (typeof file.onDependenciesChanged === 'function') {
2,420✔
666
                    file.disposables ??= [];
2,394!
667
                    file.disposables.push(
2,394✔
668
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
669
                    );
670
                }
671

672
                //register this file (and its dependencies) with the dependency graph
673
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
2,420✔
674

675
                //if this is a `source` file, add it to the source scope's dependency list
676
                if (this.isSourceBrsFile(file)) {
2,420✔
677
                    this.createSourceScope();
1,619✔
678
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
1,619✔
679
                }
680

681
                //if this is an xml file in the components folder, register it as a component
682
                if (this.isComponentsXmlFile(file)) {
2,420✔
683
                    //create a new scope for this xml file
684
                    let scope = new XmlScope(file, this);
414✔
685
                    this.addScope(scope);
414✔
686

687
                    //register this componet now that we have parsed it and know its component name
688
                    this.registerComponent(file, scope);
414✔
689

690
                    //notify plugins that the scope is created and the component is registered
691
                    this.plugins.emit('afterScopeCreate', {
414✔
692
                        program: this,
693
                        scope: scope
694
                    });
695
                }
696
            }
697

698
            return primaryFile;
2,416✔
699
        });
700
        return file as T;
2,416✔
701
    }
702

703
    /**
704
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
705
     * @param fileParam an object representing file paths
706
     * @param rootDir must be a pre-normalized path
707
     */
708
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
709
        let srcPath: string | undefined;
710
        let destPath: string | undefined;
711

712
        assert.ok(fileParam, 'fileParam is required');
2,579✔
713

714
        //lift the path vars from the incoming param
715
        if (typeof fileParam === 'string') {
2,579✔
716
            fileParam = this.removePkgPrefix(fileParam);
2,221✔
717
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,221✔
718
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,221✔
719
        } else {
720
            let param: any = fileParam;
358✔
721

722
            if (param.src) {
358✔
723
                srcPath = s`${param.src}`;
357✔
724
            }
725
            if (param.srcPath) {
358!
UNCOV
726
                srcPath = s`${param.srcPath}`;
×
727
            }
728
            if (param.dest) {
358✔
729
                destPath = s`${this.removePkgPrefix(param.dest)}`;
357✔
730
            }
731
            if (param.pkgPath) {
358!
UNCOV
732
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
733
            }
734
        }
735

736
        //if there's no srcPath, use the destPath to build an absolute srcPath
737
        if (!srcPath) {
2,579✔
738
            srcPath = s`${rootDir}/${destPath}`;
1✔
739
        }
740
        //coerce srcPath to an absolute path
741
        if (!path.isAbsolute(srcPath)) {
2,579✔
742
            srcPath = util.standardizePath(srcPath);
1✔
743
        }
744

745
        //if destPath isn't set, compute it from the other paths
746
        if (!destPath) {
2,579✔
747
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
748
        }
749

750
        assert.ok(srcPath, 'fileEntry.src is required');
2,579✔
751
        assert.ok(destPath, 'fileEntry.dest is required');
2,579✔
752

753
        return {
2,579✔
754
            srcPath: srcPath,
755
            //remove leading slash
756
            destPath: destPath.replace(/^[\/\\]+/, '')
757
        };
758
    }
759

760
    /**
761
     * Remove any leading `pkg:/` found in the path
762
     */
763
    private removePkgPrefix(path: string) {
764
        return path.replace(/^pkg:\//i, '');
2,578✔
765
    }
766

767
    /**
768
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
769
     */
770
    private isSourceBrsFile(file: BscFile) {
771
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
2,580✔
772
    }
773

774
    /**
775
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
776
     */
777
    private isComponentsXmlFile(file: BscFile): file is XmlFile {
778
        return isXmlFile(file) && !!/^(pkg:\/)?components[\/\\]/.exec(file.destPath);
2,420✔
779
    }
780

781
    /**
782
     * Ensure source scope is created.
783
     * Note: automatically called internally, and no-op if it exists already.
784
     */
785
    public createSourceScope() {
786
        if (!this.scopes.source) {
2,383✔
787
            const sourceScope = new Scope('source', this, 'scope:source');
1,575✔
788
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,575✔
789
            this.addScope(sourceScope);
1,575✔
790
            this.plugins.emit('afterScopeCreate', {
1,575✔
791
                program: this,
792
                scope: sourceScope
793
            });
794
        }
795
    }
796

797
    /**
798
     * Remove a set of files from the program
799
     * @param srcPaths can be an array of srcPath or destPath strings
800
     * @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
801
     */
802
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
803
        for (let srcPath of srcPaths) {
1✔
804
            this.removeFile(srcPath, normalizePath);
1✔
805
        }
806
    }
807

808
    /**
809
     * Remove a file from the program
810
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
811
     * @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
812
     */
813
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
27✔
814
        this.logger.debug('Program.removeFile()', filePath);
158✔
815
        const paths = this.getPaths(filePath, this.options.rootDir);
158✔
816

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

820
        for (const file of files) {
158✔
821
            //if a file has already been removed, nothing more needs to be done here
822
            if (!file || !this.hasFile(file.srcPath)) {
161✔
823
                continue;
1✔
824
            }
825
            this.diagnostics.clearForFile(file.srcPath);
160✔
826

827
            const event: BeforeFileRemoveEvent = { file: file, program: this };
160✔
828
            this.plugins.emit('beforeFileRemove', event);
160✔
829

830
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
831
            let scope = this.scopes[file.destPath];
160✔
832
            if (scope) {
160✔
833
                this.logger.debug('Removing associated scope', scope.name);
15✔
834
                const scopeDisposeEvent = {
15✔
835
                    program: this,
836
                    scope: scope
837
                };
838
                this.plugins.emit('beforeScopeDispose', scopeDisposeEvent);
15✔
839
                this.plugins.emit('onScopeDispose', scopeDisposeEvent);
15✔
840
                scope.dispose();
15✔
841
                //notify dependencies of this scope that it has been removed
842
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
15✔
843
                this.removeScope(this.scopes[file.destPath]);
15✔
844
                this.plugins.emit('afterScopeDispose', scopeDisposeEvent);
15✔
845
            }
846
            //remove the file from the program
847
            this.unassignFile(file);
160✔
848

849
            this.dependencyGraph.remove(file.dependencyGraphKey);
160✔
850

851
            //if this is a pkg:/source file, notify the `source` scope that it has changed
852
            if (this.isSourceBrsFile(file)) {
160✔
853
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
127✔
854
            }
855
            if (isBrsFile(file)) {
160✔
856
                this.logger.debug('Removing file symbol info', file.srcPath);
139✔
857

858
                if (!keepSymbolInformation) {
139✔
859
                    this.fileSymbolInformation.delete(file.pkgPath);
8✔
860
                }
861
                this.crossScopeValidation.clearResolutionsForFile(file);
139✔
862
            }
863

864
            //if this is a component, remove it from our components map
865
            if (isXmlFile(file)) {
160✔
866
                this.logger.debug('Unregistering component', file.srcPath);
15✔
867

868
                this.unregisterComponent(file);
15✔
869
            }
870
            this.logger.debug('Disposing file', file.srcPath);
160✔
871

872
            //dispose any disposable things on the file
873
            for (const disposable of file?.disposables ?? []) {
160!
874
                disposable();
154✔
875
            }
876
            //dispose file
877
            file?.dispose?.();
160!
878

879
            this.plugins.emit('afterFileRemove', event);
160✔
880
        }
881
    }
882

883
    public crossScopeValidation = new CrossScopeValidator(this);
1,819✔
884

885
    private isFirstValidation = true;
1,819✔
886

887
    /**
888
     * Traverse the entire project, and validate all scopes
889
     */
890
    public validate() {
891
        this.logger.time(LogLevel.log, ['Validating project'], () => {
1,365✔
892
            this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,365✔
893
            const programValidateEvent = {
1,365✔
894
                program: this
895
            };
896
            this.plugins.emit('beforeProgramValidate', programValidateEvent);
1,365✔
897
            this.plugins.emit('onProgramValidate', programValidateEvent);
1,365✔
898

899
            const metrics = {
1,365✔
900
                filesChanged: 0,
901
                filesValidated: 0,
902
                fileValidationTime: '',
903
                crossScopeValidationTime: '',
904
                scopesValidated: 0,
905
                totalLinkTime: '',
906
                totalScopeValidationTime: '',
907
                componentValidationTime: '',
908
                changedSymbolsTime: ''
909
            };
910

911
            const validationStopwatch = new Stopwatch();
1,365✔
912
            //validate every file
913
            const brsFilesValidated: BrsFile[] = [];
1,365✔
914
            const xmlFilesValidated: XmlFile[] = [];
1,365✔
915

916
            const afterValidateFiles: BscFile[] = [];
1,365✔
917
            const sortedFiles = Object.values(this.files).sort(firstBy(x => x.srcPath));
3,750✔
918
            this.logger.time(LogLevel.info, ['Prebuild component types'], () => {
1,365✔
919
                // cast a wide net for potential changes in components
920
                for (const file of sortedFiles) {
1,365✔
921
                    if (file.isValidated) {
2,332✔
922
                        continue;
351✔
923
                    }
924
                    if (isXmlFile(file)) {
1,981✔
925
                        this.addDeferredComponentTypeSymbolCreation(file);
341✔
926
                    } else if (isBrsFile(file)) {
1,640!
927
                        for (const scope of this.getScopesForFile(file)) {
1,640✔
928
                            if (isXmlScope(scope)) {
1,934✔
929
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
604✔
930
                            }
931
                        }
932
                    }
933
                }
934

935
                // Create reference component types for any component that changes
936
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,365✔
937
                    this.addComponentReferenceType(componentKey, componentName);
472✔
938
                }
939
            });
940

941

942
            metrics.fileValidationTime = validationStopwatch.getDurationTextFor(() => {
1,365✔
943
                //sort files by path so we get consistent results
944
                for (const file of sortedFiles) {
1,365✔
945
                    //for every unvalidated file, validate it
946
                    if (!file.isValidated) {
2,332✔
947
                        const validateFileEvent = {
1,981✔
948
                            program: this,
949
                            file: file
950
                        };
951
                        this.plugins.emit('beforeFileValidate', validateFileEvent);
1,981✔
952
                        //emit an event to allow plugins to contribute to the file validation process
953
                        this.plugins.emit('onFileValidate', validateFileEvent);
1,981✔
954
                        file.isValidated = true;
1,981✔
955
                        if (isBrsFile(file)) {
1,981✔
956
                            brsFilesValidated.push(file);
1,640✔
957
                        } else if (isXmlFile(file)) {
341!
958
                            xmlFilesValidated.push(file);
341✔
959
                        }
960
                        afterValidateFiles.push(file);
1,981✔
961
                    }
962
                }
963
                // AfterFileValidate is after all files have been validated
964
                for (const file of afterValidateFiles) {
1,365✔
965
                    const validateFileEvent = {
1,981✔
966
                        program: this,
967
                        file: file
968
                    };
969
                    this.plugins.emit('afterFileValidate', validateFileEvent);
1,981✔
970
                }
971
            }).durationText;
972

973
            metrics.filesChanged = afterValidateFiles.length;
1,365✔
974

975
            const changedComponentTypes: string[] = [];
1,365✔
976

977
            // Build component types for any component that changes
978
            this.logger.time(LogLevel.info, ['Build component types'], () => {
1,365✔
979
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,365✔
980
                    if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
472✔
981
                        changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
332✔
982
                    }
983
                }
984
                this.componentSymbolsToUpdate.clear();
1,365✔
985
            });
986

987
            // get set of changed symbols
988
            const changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,365✔
989
            metrics.changedSymbolsTime = validationStopwatch.getDurationTextFor(() => {
1,365✔
990

991
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,365!
992
                    if (isBrsFile(f)) {
1,981✔
993
                        return f.providedSymbols.changes;
1,640✔
994
                    }
995
                    return null;
341✔
996
                }).filter(x => x);
1,981✔
997

998
                // update the map of typetime dependencies
999
                for (const file of brsFilesValidated) {
1,365✔
1000
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,640✔
1001
                        // clear existing dependencies
1002
                        for (const values of this.symbolDependencies.values()) {
634✔
1003
                            values.delete(symbolName);
59✔
1004
                        }
1005

1006
                        // map types to the set of types that depend upon them
1007
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
634!
1008
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
167✔
1009
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
167✔
1010
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
147✔
1011
                            }
1012
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
167✔
1013
                            symbolsDependentUpon.add(symbolName);
167✔
1014
                        }
1015
                    }
1016
                }
1017

1018
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,365✔
1019
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,280✔
1020
                    const changedSymbolSet = new Set<string>();
2,730✔
1021
                    for (const changeSet of changedSymbolsSetArr) {
2,730✔
1022
                        for (const change of changeSet) {
3,280✔
1023
                            changedSymbolSet.add(change);
3,323✔
1024
                        }
1025
                    }
1026
                    changedSymbols.set(flag, changedSymbolSet);
2,730✔
1027
                }
1028

1029
                // update changed symbol set with any changed component
1030
                for (const changedComponentType of changedComponentTypes) {
1,365✔
1031
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
332✔
1032
                }
1033

1034
                // Add any additional types that depend on a changed type
1035
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1036
                const dependentTypesChanged = new Set<string>();
1,365✔
1037
                let foundDependentTypes = false;
1,365✔
1038
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,365✔
1039
                do {
1,365✔
1040
                    foundDependentTypes = false;
1,370✔
1041
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
1,370✔
1042
                    for (const changedSymbol of allChangedTypesSofar) {
1,370✔
1043
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
968✔
1044
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
968✔
1045
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
169✔
1046
                                foundDependentTypes = true;
5✔
1047
                                dependentTypesChanged.add(symbolName);
5✔
1048
                            }
1049
                        }
1050
                    }
1051
                } while (foundDependentTypes);
1052

1053
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedTypeSymbols, ...dependentTypesChanged]));
1,365✔
1054
            }).durationText;
1055

1056
            if (this.options.logLevel === LogLevel.debug) {
1,365!
NEW
1057
                const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
NEW
1058
                this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
NEW
1059
                const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
NEW
1060
                this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1061
            }
1062
            const filesToBeValidatedInScopeContext = new Set<BscFile>(afterValidateFiles);
1,365✔
1063

1064
            metrics.crossScopeValidationTime = validationStopwatch.getDurationTextFor(() => {
1,365✔
1065
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,365✔
1066
                this.crossScopeValidation.buildComponentsMap();
1,365✔
1067
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,365✔
1068
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,365✔
1069
                for (const file of filesToRevalidate) {
1,365✔
1070
                    filesToBeValidatedInScopeContext.add(file);
409✔
1071
                }
1072
            }).durationText;
1073

1074
            metrics.filesValidated = filesToBeValidatedInScopeContext.size;
1,365✔
1075

1076
            let linkTime = 0;
1,365✔
1077
            let validationTime = 0;
1,365✔
1078
            let scopesValidated = 0;
1,365✔
1079
            let changedFiles = new Set<BscFile>(afterValidateFiles);
1,365✔
1080
            this.currentScopeValidationOptions = {
1,365✔
1081
                filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1082
                changedSymbols: changedSymbols,
1083
                changedFiles: changedFiles,
1084
                initialValidation: this.isFirstValidation
1085
            };
1086
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
1,365✔
1087
                //sort the scope names so we get consistent results
1088
                const scopeNames = this.getSortedScopeNames();
1,365✔
1089
                for (const file of filesToBeValidatedInScopeContext) {
1,365✔
1090
                    if (isBrsFile(file)) {
2,109✔
1091
                        file.validationSegmenter.unValidateAllSegments();
1,768✔
1092
                        for (const scope of this.getScopesForFile(file)) {
1,768✔
1093
                            scope.invalidate();
2,064✔
1094
                        }
1095
                    }
1096
                }
1097
                for (let scopeName of scopeNames) {
1,365✔
1098
                    let scope = this.scopes[scopeName];
3,122✔
1099
                    const scopeValidated = scope.validate(this.currentScopeValidationOptions);
3,122✔
1100
                    if (scopeValidated) {
3,122✔
1101
                        scopesValidated++;
1,699✔
1102
                    }
1103
                    linkTime += scope.validationMetrics.linkTime;
3,122✔
1104
                    validationTime += scope.validationMetrics.validationTime;
3,122✔
1105
                }
1106
            });
1107
            metrics.scopesValidated = scopesValidated;
1,365✔
1108
            validationStopwatch.totalMilliseconds = linkTime;
1,365✔
1109
            metrics.totalLinkTime = validationStopwatch.getDurationText();
1,365✔
1110

1111
            validationStopwatch.totalMilliseconds = validationTime;
1,365✔
1112
            metrics.totalScopeValidationTime = validationStopwatch.getDurationText();
1,365✔
1113

1114
            metrics.componentValidationTime = validationStopwatch.getDurationTextFor(() => {
1,365✔
1115
                this.detectDuplicateComponentNames();
1,365✔
1116
            }).durationText;
1117

1118
            this.logValidationMetrics(metrics);
1,365✔
1119

1120
            this.isFirstValidation = false;
1,365✔
1121

1122
            this.plugins.emit('afterProgramValidate', programValidateEvent);
1,365✔
1123
        });
1124
    }
1125

1126
    // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
1127
    private logValidationMetrics(metrics: { [key: string]: number | string }) {
1128
        let logs = [] as string[];
1,365✔
1129
        for (const key in metrics) {
1,365✔
1130
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
12,285✔
1131
        }
1132
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
1,365✔
1133
    }
1134

1135
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1136
        const scopesForCrossScopeValidation = [];
1,365✔
1137
        for (let scopeName of this.getSortedScopeNames()) {
1,365✔
1138
            let scope = this.scopes[scopeName];
3,122✔
1139
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,122✔
1140
                scopesForCrossScopeValidation.push(scope);
1,731✔
1141
            }
1142
        }
1143
        return scopesForCrossScopeValidation;
1,365✔
1144
    }
1145

1146
    /**
1147
     * Flag all duplicate component names
1148
     */
1149
    private detectDuplicateComponentNames() {
1150
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,365✔
1151
            const file = this.files[filePath];
2,332✔
1152
            //if this is an XmlFile, and it has a valid `componentName` property
1153
            if (isXmlFile(file) && file.componentName?.text) {
2,332✔
1154
                let lowerName = file.componentName.text.toLowerCase();
481✔
1155
                if (!map[lowerName]) {
481✔
1156
                    map[lowerName] = [];
478✔
1157
                }
1158
                map[lowerName].push(file);
481✔
1159
            }
1160
            return map;
2,332✔
1161
        }, {});
1162

1163
        for (let name in componentsByName) {
1,365✔
1164
            const xmlFiles = componentsByName[name];
478✔
1165
            //add diagnostics for every duplicate component with this name
1166
            if (xmlFiles.length > 1) {
478✔
1167
                for (let xmlFile of xmlFiles) {
3✔
1168
                    const { componentName } = xmlFile;
6✔
1169
                    this.diagnostics.register({
6✔
1170
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1171
                        location: xmlFile.componentName.location,
1172
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1173
                            return {
6✔
1174
                                location: x.componentName.location,
1175
                                message: 'Also defined here'
1176
                            };
1177
                        })
1178
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1179
                }
1180
            }
1181
        }
1182
    }
1183

1184
    /**
1185
     * Get the files for a list of filePaths
1186
     * @param filePaths can be an array of srcPath or a destPath strings
1187
     * @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
1188
     */
1189
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1190
        return filePaths
29✔
1191
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1192
            .filter(file => file !== undefined) as T[];
39✔
1193
    }
1194

1195
    /**
1196
     * Get the file at the given path
1197
     * @param filePath can be a srcPath or a destPath
1198
     * @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
1199
     */
1200
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
17,759✔
1201
        if (typeof filePath !== 'string') {
24,308✔
1202
            return undefined;
3,429✔
1203
            //is the path absolute (or the `virtual:` prefix)
1204
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
20,879✔
1205
            return this.files[
4,654✔
1206
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
4,654!
1207
            ] as T;
1208
        } else if (util.isUriLike(filePath)) {
16,225✔
1209
            const path = URI.parse(filePath).fsPath;
693✔
1210
            return this.files[
693✔
1211
                (normalizePath ? util.standardizePath(path) : path).toLowerCase()
693!
1212
            ] as T;
1213
        } else {
1214
            return this.destMap.get(
15,532✔
1215
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
15,532✔
1216
            ) as T;
1217
        }
1218
    }
1219

1220
    private sortedScopeNames: string[] = undefined;
1,819✔
1221

1222
    /**
1223
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1224
     */
1225
    private getSortedScopeNames() {
1226
        if (!this.sortedScopeNames) {
10,906✔
1227
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,316✔
1228
                if (a === 'global') {
1,907!
UNCOV
1229
                    return -1;
×
1230
                } else if (b === 'global') {
1,907✔
1231
                    return 1;
1,298✔
1232
                }
1233
                if (a === 'source') {
609✔
1234
                    return -1;
27✔
1235
                } else if (b === 'source') {
582✔
1236
                    return 1;
141✔
1237
                }
1238
                if (a < b) {
441✔
1239
                    return -1;
189✔
1240
                } else if (b < a) {
252!
1241
                    return 1;
252✔
1242
                }
UNCOV
1243
                return 0;
×
1244
            });
1245
        }
1246
        return this.sortedScopeNames;
10,906✔
1247
    }
1248

1249
    /**
1250
     * Get a list of all scopes the file is loaded into
1251
     * @param file the file
1252
     */
1253
    public getScopesForFile(file: BscFile | string) {
1254
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,128✔
1255

1256
        let result = [] as Scope[];
4,128✔
1257
        if (resolvedFile) {
4,128✔
1258
            const scopeKeys = this.getSortedScopeNames();
4,127✔
1259
            for (let key of scopeKeys) {
4,127✔
1260
                let scope = this.scopes[key];
39,566✔
1261

1262
                if (scope.hasFile(resolvedFile)) {
39,566✔
1263
                    result.push(scope);
4,731✔
1264
                }
1265
            }
1266
        }
1267
        return result;
4,128✔
1268
    }
1269

1270
    /**
1271
     * Get the first found scope for a file.
1272
     */
1273
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1274
        const scopeKeys = this.getSortedScopeNames();
4,049✔
1275
        for (let key of scopeKeys) {
4,049✔
1276
            let scope = this.scopes[key];
18,497✔
1277

1278
            if (scope.hasFile(file)) {
18,497✔
1279
                return scope;
2,941✔
1280
            }
1281
        }
1282
    }
1283

1284
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1285
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1286
        const filesSearched = new Set<BrsFile>();
39✔
1287
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1288
        let lowerName = name?.toLowerCase();
39!
1289

1290
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1291
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1292
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1293
                if (!results.has(statement)) {
36!
1294
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1295
                }
1296
            }
1297
        }
1298

1299
        //look through all files in scope for matches
1300
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1301
            for (const file of scope.getAllFiles()) {
39✔
1302
                //skip non-brs files, or files we've already processed
1303
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1304
                    continue;
3✔
1305
                }
1306
                filesSearched.add(file);
42✔
1307

1308
                file.ast.walk(createVisitor({
42✔
1309
                    FunctionStatement: (statement: FunctionStatement) => {
1310
                        addToResults(statement, file);
95✔
1311
                    },
1312
                    MethodStatement: (statement: MethodStatement) => {
1313
                        addToResults(statement, file);
3✔
1314
                    }
1315
                }), {
1316
                    walkMode: WalkMode.visitStatements
1317
                });
1318
            }
1319
        }
1320
        return [...results.values()];
39✔
1321
    }
1322

1323
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1324
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1325
        const filesSearched = new Set<BrsFile>();
8✔
1326

1327
        //get all function names for the xml file and parents
1328
        let funcNames = new Set<string>();
8✔
1329
        let currentScope = scope;
8✔
1330
        while (isXmlScope(currentScope)) {
8✔
1331
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
14✔
1332
                if (!filterName || name === filterName) {
14!
1333
                    funcNames.add(name);
14✔
1334
                }
1335
            }
1336
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1337
        }
1338

1339
        //look through all files in scope for matches
1340
        for (const file of scope.getOwnFiles()) {
8✔
1341
            //skip non-brs files, or files we've already processed
1342
            if (!isBrsFile(file) || filesSearched.has(file)) {
16✔
1343
                continue;
8✔
1344
            }
1345
            filesSearched.add(file);
8✔
1346

1347
            file.ast.walk(createVisitor({
8✔
1348
                FunctionStatement: (statement: FunctionStatement) => {
1349
                    if (funcNames.has(statement.tokens.name.text)) {
13!
1350
                        if (!results.has(statement)) {
13!
1351
                            results.set(statement, { item: statement, file: file });
13✔
1352
                        }
1353
                    }
1354
                }
1355
            }), {
1356
                walkMode: WalkMode.visitStatements
1357
            });
1358
        }
1359
        return [...results.values()];
8✔
1360
    }
1361

1362
    /**
1363
     * Find all available completion items at the given position
1364
     * @param filePath can be a srcPath or a destPath
1365
     * @param position the position (line & column) where completions should be found
1366
     */
1367
    public getCompletions(filePath: string, position: Position) {
1368
        let file = this.getFile(filePath);
117✔
1369
        if (!file) {
117!
UNCOV
1370
            return [];
×
1371
        }
1372

1373
        //find the scopes for this file
1374
        let scopes = this.getScopesForFile(file);
117✔
1375

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

1379
        const event: ProvideCompletionsEvent = {
117✔
1380
            program: this,
1381
            file: file,
1382
            scopes: scopes,
1383
            position: position,
1384
            completions: []
1385
        };
1386

1387
        this.plugins.emit('beforeProvideCompletions', event);
117✔
1388

1389
        this.plugins.emit('provideCompletions', event);
117✔
1390

1391
        this.plugins.emit('afterProvideCompletions', event);
117✔
1392

1393
        return event.completions;
117✔
1394
    }
1395

1396
    /**
1397
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1398
     */
1399
    public getWorkspaceSymbols() {
1400
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1401
            program: this,
1402
            workspaceSymbols: []
1403
        };
1404
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1405
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1406
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1407
        return event.workspaceSymbols;
22✔
1408
    }
1409

1410
    /**
1411
     * Given a position in a file, if the position is sitting on some type of identifier,
1412
     * go to the definition of that identifier (where this thing was first defined)
1413
     */
1414
    public getDefinition(srcPath: string, position: Position): Location[] {
1415
        let file = this.getFile(srcPath);
18✔
1416
        if (!file) {
18!
UNCOV
1417
            return [];
×
1418
        }
1419

1420
        const event: ProvideDefinitionEvent = {
18✔
1421
            program: this,
1422
            file: file,
1423
            position: position,
1424
            definitions: []
1425
        };
1426

1427
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1428
        this.plugins.emit('provideDefinition', event);
18✔
1429
        this.plugins.emit('afterProvideDefinition', event);
18✔
1430
        return event.definitions;
18✔
1431
    }
1432

1433
    /**
1434
     * Get hover information for a file and position
1435
     */
1436
    public getHover(srcPath: string, position: Position): Hover[] {
1437
        let file = this.getFile(srcPath);
68✔
1438
        let result: Hover[];
1439
        if (file) {
68!
1440
            const event = {
68✔
1441
                program: this,
1442
                file: file,
1443
                position: position,
1444
                scopes: this.getScopesForFile(file),
1445
                hovers: []
1446
            } as ProvideHoverEvent;
1447
            this.plugins.emit('beforeProvideHover', event);
68✔
1448
            this.plugins.emit('provideHover', event);
68✔
1449
            this.plugins.emit('afterProvideHover', event);
68✔
1450
            result = event.hovers;
68✔
1451
        }
1452

1453
        return result ?? [];
68!
1454
    }
1455

1456
    /**
1457
     * Get full list of document symbols for a file
1458
     * @param srcPath path to the file
1459
     */
1460
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1461
        let file = this.getFile(srcPath);
18✔
1462
        if (file) {
18!
1463
            const event: ProvideDocumentSymbolsEvent = {
18✔
1464
                program: this,
1465
                file: file,
1466
                documentSymbols: []
1467
            };
1468
            this.plugins.emit('beforeProvideDocumentSymbols', event);
18✔
1469
            this.plugins.emit('provideDocumentSymbols', event);
18✔
1470
            this.plugins.emit('afterProvideDocumentSymbols', event);
18✔
1471
            return event.documentSymbols;
18✔
1472
        } else {
UNCOV
1473
            return undefined;
×
1474
        }
1475
    }
1476

1477
    /**
1478
     * Compute code actions for the given file and range
1479
     */
1480
    public getCodeActions(srcPath: string, range: Range) {
1481
        const codeActions = [] as CodeAction[];
13✔
1482
        const file = this.getFile(srcPath);
13✔
1483
        if (file) {
13✔
1484
            const fileUri = util.pathToUri(file?.srcPath);
12!
1485
            const diagnostics = this
12✔
1486
                //get all current diagnostics (filtered by diagnostic filters)
1487
                .getDiagnostics()
1488
                //only keep diagnostics related to this file
1489
                .filter(x => x.location?.uri === fileUri)
25✔
1490
                //only keep diagnostics that touch this range
1491
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
12✔
1492

1493
            const scopes = this.getScopesForFile(file);
12✔
1494

1495
            this.plugins.emit('onGetCodeActions', {
12✔
1496
                program: this,
1497
                file: file,
1498
                range: range,
1499
                diagnostics: diagnostics,
1500
                scopes: scopes,
1501
                codeActions: codeActions
1502
            });
1503
        }
1504
        return codeActions;
13✔
1505
    }
1506

1507
    /**
1508
     * Get semantic tokens for the specified file
1509
     */
1510
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1511
        const file = this.getFile(srcPath);
24✔
1512
        if (file) {
24!
1513
            const result = [] as SemanticToken[];
24✔
1514
            this.plugins.emit('onGetSemanticTokens', {
24✔
1515
                program: this,
1516
                file: file,
1517
                scopes: this.getScopesForFile(file),
1518
                semanticTokens: result
1519
            });
1520
            return result;
24✔
1521
        }
1522
    }
1523

1524
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1525
        let file: BrsFile = this.getFile(filepath);
185✔
1526
        if (!file || !isBrsFile(file)) {
185✔
1527
            return [];
3✔
1528
        }
1529
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1530
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1531
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1532
    }
1533

1534
    public getReferences(srcPath: string, position: Position): Location[] {
1535
        //find the file
1536
        let file = this.getFile(srcPath);
4✔
1537

1538
        const event: ProvideReferencesEvent = {
4✔
1539
            program: this,
1540
            file: file,
1541
            position: position,
1542
            references: []
1543
        };
1544

1545
        this.plugins.emit('beforeProvideReferences', event);
4✔
1546
        this.plugins.emit('provideReferences', event);
4✔
1547
        this.plugins.emit('afterProvideReferences', event);
4✔
1548

1549
        return event.references;
4✔
1550
    }
1551

1552
    /**
1553
     * Transpile a single file and get the result as a string.
1554
     * This does not write anything to the file system.
1555
     *
1556
     * This should only be called by `LanguageServer`.
1557
     * Internal usage should call `_getTranspiledFileContents` instead.
1558
     * @param filePath can be a srcPath or a destPath
1559
     */
1560
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1561
        const file = this.getFile(filePath);
317✔
1562

1563
        return this.getTranspiledFileContentsPipeline.run(async () => {
317✔
1564

1565
            const result = {
317✔
1566
                destPath: file.destPath,
1567
                pkgPath: file.pkgPath,
1568
                srcPath: file.srcPath
1569
            } as FileTranspileResult;
1570

1571
            const expectedPkgPath = file.pkgPath.toLowerCase();
317✔
1572
            const expectedMapPath = `${expectedPkgPath}.map`;
317✔
1573
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
317✔
1574

1575
            //add a temporary plugin to tap into the file writing process
1576
            const plugin = this.plugins.addFirst({
317✔
1577
                name: 'getTranspiledFileContents',
1578
                beforeWriteFile: (event) => {
1579
                    const pkgPath = event.file.pkgPath.toLowerCase();
990✔
1580
                    switch (pkgPath) {
990✔
1581
                        //this is the actual transpiled file
1582
                        case expectedPkgPath:
990✔
1583
                            result.code = event.file.data.toString();
317✔
1584
                            break;
317✔
1585
                        //this is the sourcemap
1586
                        case expectedMapPath:
1587
                            result.map = event.file.data.toString();
170✔
1588
                            break;
170✔
1589
                        //this is the typedef
1590
                        case expectedTypedefPkgPath:
1591
                            result.typedef = event.file.data.toString();
8✔
1592
                            break;
8✔
1593
                        default:
1594
                        //no idea what this file is. just ignore it
1595
                    }
1596
                    //mark every file as processed so it they don't get written to the output directory
1597
                    event.processedFiles.add(event.file);
990✔
1598
                }
1599
            });
1600

1601
            try {
317✔
1602
                //now that the plugin has been registered, run the build with just this file
1603
                await this.build({
317✔
1604
                    files: [file]
1605
                });
1606
            } finally {
1607
                this.plugins.remove(plugin);
317✔
1608
            }
1609
            return result;
317✔
1610
        });
1611
    }
1612
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,819✔
1613

1614
    /**
1615
     * Get the absolute output path for a file
1616
     */
1617
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1618
        return s`${stagingDir}/${file.pkgPath}`;
1,827✔
1619
    }
1620

1621
    private getStagingDir(stagingDir?: string) {
1622
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
715✔
1623
        if (!result) {
715✔
1624
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
529✔
1625
        }
1626
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
715!
1627
        return result;
715✔
1628
    }
1629

1630
    /**
1631
     * Prepare the program for building
1632
     * @param files the list of files that should be prepared
1633
     */
1634
    private async prepare(files: BscFile[]) {
1635
        const programEvent: PrepareProgramEvent = {
358✔
1636
            program: this,
1637
            editor: this.editor,
1638
            files: files
1639
        };
1640

1641
        //assign an editor to every file
1642
        for (const file of programEvent.files) {
358✔
1643
            //if the file doesn't have an editor yet, assign one now
1644
            if (!file.editor) {
726✔
1645
                file.editor = new Editor();
679✔
1646
            }
1647
        }
1648

1649
        //sort the entries to make transpiling more deterministic
1650
        programEvent.files.sort((a, b) => {
358✔
1651
            if (a.pkgPath < b.pkgPath) {
383✔
1652
                return -1;
322✔
1653
            } else if (a.pkgPath > b.pkgPath) {
61!
1654
                return 1;
61✔
1655
            } else {
UNCOV
1656
                return 1;
×
1657
            }
1658
        });
1659

1660
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
358✔
1661
        await this.plugins.emitAsync('prepareProgram', programEvent);
358✔
1662

1663
        const stagingDir = this.getStagingDir();
358✔
1664

1665
        const entries: TranspileObj[] = [];
358✔
1666

1667
        for (const file of files) {
358✔
1668
            const scope = this.getFirstScopeForFile(file);
726✔
1669
            //link the symbol table for all the files in this scope
1670
            scope?.linkSymbolTable();
726✔
1671

1672
            //if the file doesn't have an editor yet, assign one now
1673
            if (!file.editor) {
726!
UNCOV
1674
                file.editor = new Editor();
×
1675
            }
1676
            const event = {
726✔
1677
                program: this,
1678
                file: file,
1679
                editor: file.editor,
1680
                scope: scope,
1681
                outputPath: this.getOutputPath(file, stagingDir)
1682
            } as PrepareFileEvent & { outputPath: string };
1683

1684
            await this.plugins.emitAsync('beforePrepareFile', event);
726✔
1685
            await this.plugins.emitAsync('prepareFile', event);
726✔
1686
            await this.plugins.emitAsync('afterPrepareFile', event);
726✔
1687

1688
            //TODO remove this in v1
1689
            entries.push(event);
726✔
1690

1691
            //unlink the symbolTable so the next loop iteration can link theirs
1692
            scope?.unlinkSymbolTable();
726✔
1693
        }
1694

1695
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
358✔
1696
        return files;
358✔
1697
    }
1698

1699
    /**
1700
     * Generate the contents of every file
1701
     */
1702
    private async serialize(files: BscFile[]) {
1703

1704
        const allFiles = new Map<BscFile, SerializedFile[]>();
357✔
1705

1706
        //exclude prunable files if that option is enabled
1707
        if (this.options.pruneEmptyCodeFiles === true) {
357✔
1708
            files = files.filter(x => x.canBePruned !== true);
9✔
1709
        }
1710

1711
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
357✔
1712
            program: this,
1713
            files: files,
1714
            result: allFiles
1715
        });
1716
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
357✔
1717

1718
        // serialize each file
1719
        for (const file of files) {
357✔
1720
            let scope = this.getFirstScopeForFile(file);
723✔
1721

1722
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1723
            if (!scope) {
723✔
1724
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
368✔
1725
                scope.getAllFiles = () => [file];
3,299✔
1726
                scope.getOwnFiles = scope.getAllFiles;
368✔
1727
            }
1728

1729
            //link the symbol table for all the files in this scope
1730
            scope?.linkSymbolTable();
723!
1731
            const event: SerializeFileEvent = {
723✔
1732
                program: this,
1733
                file: file,
1734
                scope: scope,
1735
                result: allFiles
1736
            };
1737
            await this.plugins.emitAsync('beforeSerializeFile', event);
723✔
1738
            await this.plugins.emitAsync('serializeFile', event);
723✔
1739
            await this.plugins.emitAsync('afterSerializeFile', event);
723✔
1740
            //unlink the symbolTable so the next loop iteration can link theirs
1741
            scope?.unlinkSymbolTable();
723!
1742
        }
1743

1744
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
357✔
1745

1746
        return allFiles;
357✔
1747
    }
1748

1749
    /**
1750
     * Write the entire project to disk
1751
     */
1752
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1753
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
357✔
1754
            program: this,
1755
            files: files,
1756
            stagingDir: stagingDir
1757
        });
1758
        //empty the staging directory
1759
        await fsExtra.emptyDir(stagingDir);
357✔
1760

1761
        const serializedFiles = [...files]
357✔
1762
            .map(([, serializedFiles]) => serializedFiles)
723✔
1763
            .flat();
1764

1765
        //write all the files to disk (asynchronously)
1766
        await Promise.all(
357✔
1767
            serializedFiles.map(async (file) => {
1768
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,101✔
1769
                    program: this,
1770
                    file: file,
1771
                    outputPath: this.getOutputPath(file, stagingDir),
1772
                    processedFiles: new Set<SerializedFile>()
1773
                });
1774

1775
                await this.plugins.emitAsync('writeFile', event);
1,101✔
1776

1777
                await this.plugins.emitAsync('afterWriteFile', event);
1,101✔
1778
            })
1779
        );
1780

1781
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
357✔
1782
    }
1783

1784
    private buildPipeline = new ActionPipeline();
1,819✔
1785

1786
    /**
1787
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1788
     * @param options the list of options used to build the program
1789
     */
1790
    public async build(options?: ProgramBuildOptions) {
1791
        //run a single build at a time
1792
        await this.buildPipeline.run(async () => {
357✔
1793
            const stagingDir = this.getStagingDir(options?.stagingDir);
357✔
1794

1795
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
357✔
1796
                program: this,
1797
                editor: this.editor,
1798
                files: options?.files ?? Object.values(this.files)
2,142✔
1799
            });
1800

1801
            //prepare the program (and files) for building
1802
            event.files = await this.prepare(event.files);
357✔
1803

1804
            //stage the entire program
1805
            const serializedFilesByFile = await this.serialize(event.files);
357✔
1806

1807
            await this.write(stagingDir, serializedFilesByFile);
357✔
1808

1809
            await this.plugins.emitAsync('afterBuildProgram', event);
357✔
1810

1811
            //undo all edits for the program
1812
            this.editor.undoAll();
357✔
1813
            //undo all edits for each file
1814
            for (const file of event.files) {
357✔
1815
                file.editor.undoAll();
724✔
1816
            }
1817
        });
1818
    }
1819

1820
    /**
1821
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1822
     */
1823
    public findFilesForFunction(functionName: string) {
1824
        const files = [] as BscFile[];
7✔
1825
        const lowerFunctionName = functionName.toLowerCase();
7✔
1826
        //find every file with this function defined
1827
        for (const file of Object.values(this.files)) {
7✔
1828
            if (isBrsFile(file)) {
25✔
1829
                //TODO handle namespace-relative function calls
1830
                //if the file has a function with this name
1831
                // eslint-disable-next-line @typescript-eslint/dot-notation
1832
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
1833
                    files.push(file);
2✔
1834
                }
1835
            }
1836
        }
1837
        return files;
7✔
1838
    }
1839

1840
    /**
1841
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1842
     */
1843
    public findFilesForClass(className: string) {
1844
        const files = [] as BscFile[];
7✔
1845
        const lowerClassName = className.toLowerCase();
7✔
1846
        //find every file with this class defined
1847
        for (const file of Object.values(this.files)) {
7✔
1848
            if (isBrsFile(file)) {
25✔
1849
                //TODO handle namespace-relative classes
1850
                //if the file has a function with this name
1851

1852
                // eslint-disable-next-line @typescript-eslint/dot-notation
1853
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
1854
                    files.push(file);
1✔
1855
                }
1856
            }
1857
        }
1858
        return files;
7✔
1859
    }
1860

1861
    public findFilesForNamespace(name: string) {
1862
        const files = [] as BscFile[];
7✔
1863
        const lowerName = name.toLowerCase();
7✔
1864
        //find every file with this class defined
1865
        for (const file of Object.values(this.files)) {
7✔
1866
            if (isBrsFile(file)) {
25✔
1867

1868
                // eslint-disable-next-line @typescript-eslint/dot-notation
1869
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
1870
                    const namespaceName = x.name.toLowerCase();
7✔
1871
                    return (
7✔
1872
                        //the namespace name matches exactly
1873
                        namespaceName === lowerName ||
9✔
1874
                        //the full namespace starts with the name (honoring the part boundary)
1875
                        namespaceName.startsWith(lowerName + '.')
1876
                    );
1877
                })) {
1878
                    files.push(file);
6✔
1879
                }
1880
            }
1881
        }
1882

1883
        return files;
7✔
1884
    }
1885

1886
    public findFilesForEnum(name: string) {
1887
        const files = [] as BscFile[];
8✔
1888
        const lowerName = name.toLowerCase();
8✔
1889
        //find every file with this enum defined
1890
        for (const file of Object.values(this.files)) {
8✔
1891
            if (isBrsFile(file)) {
26✔
1892
                // eslint-disable-next-line @typescript-eslint/dot-notation
1893
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
1894
                    files.push(file);
1✔
1895
                }
1896
            }
1897
        }
1898
        return files;
8✔
1899
    }
1900

1901
    private _manifest: Map<string, string>;
1902

1903
    /**
1904
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1905
     * @param parsedManifest The manifest map to read from and modify
1906
     */
1907
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1908
        // Lift the bs_consts defined in the manifest
1909
        let bsConsts = getBsConst(parsedManifest, false);
15✔
1910

1911
        // Override or delete any bs_consts defined in the bs config
1912
        for (const key in this.options?.manifest?.bs_const) {
15!
1913
            const value = this.options.manifest.bs_const[key];
3✔
1914
            if (value === null) {
3✔
1915
                bsConsts.delete(key);
1✔
1916
            } else {
1917
                bsConsts.set(key, value);
2✔
1918
            }
1919
        }
1920

1921
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1922
        let constString = '';
15✔
1923
        for (const [key, value] of bsConsts) {
15✔
1924
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1925
        }
1926

1927
        // Set the updated bs_const value
1928
        parsedManifest.set('bs_const', constString);
15✔
1929
    }
1930

1931
    /**
1932
     * Try to find and load the manifest into memory
1933
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1934
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1935
     */
1936
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,490✔
1937
        //if we already have a manifest instance, and should not replace...then don't replace
1938
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,496!
UNCOV
1939
            return;
×
1940
        }
1941
        let manifestPath = manifestFileObj
1,496✔
1942
            ? manifestFileObj.src
1,496✔
1943
            : path.join(this.options.rootDir, 'manifest');
1944

1945
        try {
1,496✔
1946
            // we only load this manifest once, so do it sync to improve speed downstream
1947
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,496✔
1948
            const parsedManifest = parseManifest(contents);
15✔
1949
            this.buildBsConstsIntoParsedManifest(parsedManifest);
15✔
1950
            this._manifest = parsedManifest;
15✔
1951
        } catch (e) {
1952
            this._manifest = new Map();
1,481✔
1953
        }
1954
    }
1955

1956
    /**
1957
     * Get a map of the manifest information
1958
     */
1959
    public getManifest() {
1960
        if (!this._manifest) {
2,340✔
1961
            this.loadManifest();
1,489✔
1962
        }
1963
        return this._manifest;
2,340✔
1964
    }
1965

1966
    public dispose() {
1967
        this.plugins.emit('beforeProgramDispose', { program: this });
1,671✔
1968

1969
        for (let filePath in this.files) {
1,671✔
1970
            this.files[filePath]?.dispose?.();
2,093!
1971
        }
1972
        for (let name in this.scopes) {
1,671✔
1973
            this.scopes[name]?.dispose?.();
3,532!
1974
        }
1975
        this.globalScope?.dispose?.();
1,671!
1976
        this.dependencyGraph?.dispose?.();
1,671!
1977
    }
1978
}
1979

1980
export interface FileTranspileResult {
1981
    srcPath: string;
1982
    destPath: string;
1983
    pkgPath: string;
1984
    code: string;
1985
    map: string;
1986
    typedef: string;
1987
}
1988

1989

1990
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
1991
    constructor(
1992
        public program: Program,
2,416✔
1993
        public srcPath: string,
2,416✔
1994
        public destPath: string,
2,416✔
1995
        public data: LazyFileData,
2,416✔
1996
        public fileFactory: FileFactory
2,416✔
1997
    ) {
1998
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,416!
1999
    }
2000

2001
    public srcExtension: string;
2002

2003
    public files: TFile[] = [];
2,416✔
2004
}
2005

2006
export interface ProgramBuildOptions {
2007
    /**
2008
     * The directory where the final built files should be placed. This directory will be cleared before running
2009
     */
2010
    stagingDir?: string;
2011
    /**
2012
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2013
     * Typically you will want to leave this blank
2014
     */
2015
    files?: BscFile[];
2016
}
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