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

rokucommunity / brighterscript / #13326

25 Nov 2024 05:15PM UTC coverage: 86.867%. Remained the same
#13326

push

web-flow
Merge 0ff800164 into 2a6afd921

11938 of 14535 branches covered (82.13%)

Branch coverage included in aggregate %.

324 of 340 new or added lines in 31 files covered. (95.29%)

240 existing lines in 20 files now uncovered.

12972 of 14141 relevant lines covered (91.73%)

32490.33 hits per line

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

93.62
/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 } 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,795✔
80
        this.logger = logger ?? createLogger(options);
1,795✔
81
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
1,795✔
82
        this.diagnostics = diagnosticsManager || new DiagnosticManager();
1,795✔
83

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

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

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

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

97
        this.fileFactory = new FileFactory(this);
1,795✔
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,795✔
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,795✔
116
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
1,795✔
117
        this.scopes.global = this.globalScope;
1,795✔
118

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

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

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

131

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

160
        return nodeType;
332,075✔
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,795✔
169
        this.globalScope.symbolTable.addSymbol('double', undefined, DoubleType.instance, SymbolTypeFlag.typetime);
1,795✔
170
        this.globalScope.symbolTable.addSymbol('dynamic', undefined, DynamicType.instance, SymbolTypeFlag.typetime);
1,795✔
171
        this.globalScope.symbolTable.addSymbol('float', undefined, FloatType.instance, SymbolTypeFlag.typetime);
1,795✔
172
        this.globalScope.symbolTable.addSymbol('function', undefined, new FunctionType(), SymbolTypeFlag.typetime);
1,795✔
173
        this.globalScope.symbolTable.addSymbol('integer', undefined, IntegerType.instance, SymbolTypeFlag.typetime);
1,795✔
174
        this.globalScope.symbolTable.addSymbol('longinteger', undefined, LongIntegerType.instance, SymbolTypeFlag.typetime);
1,795✔
175
        this.globalScope.symbolTable.addSymbol('object', undefined, new ObjectType(), SymbolTypeFlag.typetime);
1,795✔
176
        this.globalScope.symbolTable.addSymbol('string', undefined, StringType.instance, SymbolTypeFlag.typetime);
1,795✔
177
        this.globalScope.symbolTable.addSymbol('void', undefined, VoidType.instance, SymbolTypeFlag.typetime);
1,795✔
178

179
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
789,587✔
180

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

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

192
        for (const componentData of Object.values(components) as BRSComponentData[]) {
1,795✔
193
            const nodeType = new InterfaceType(componentData.name);
116,675✔
194
            nodeType.addBuiltInInterfaces();
116,675✔
195
            nodeType.isBuiltIn = true;
116,675✔
196
            if (componentData.name !== 'roSGNode') {
116,675✔
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);
114,880✔
199
            }
200
        }
201

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

206
        for (const eventData of Object.values(events) as BRSEventData[]) {
1,795✔
207
            const nodeType = new InterfaceType(eventData.name);
32,310✔
208
            nodeType.addBuiltInInterfaces();
32,310✔
209
            nodeType.isBuiltIn = true;
32,310✔
210
            this.globalScope.symbolTable.addSymbol(eventData.name, { description: eventData.description }, nodeType, SymbolTypeFlag.typetime);
32,310✔
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,795✔
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,795✔
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,795✔
237

238

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

244

245
    private componentsTable = new SymbolTable('Custom Components');
1,795✔
246

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

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

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

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

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

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

284

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

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

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

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

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

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

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

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

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

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

396
        this.syncComponentDependencyGraph(arr);
13✔
397
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
13✔
398
    }
399

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

413
    private getComponentKey(xmlFile: XmlFile) {
414
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
2,066✔
415
    }
416

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

435
            componentScope.linkSymbolTable();
463✔
436
            const componentType = componentScope.getComponentType();
463✔
437
            if (componentType) {
463!
438
                this.componentsTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
463✔
439
            }
440
            const isComponentTypeDifferent = !previousComponentType || isReferenceType(previousComponentType) || !componentType.isEqual(previousComponentType);
463✔
441
            componentScope.unlinkSymbolTable();
463✔
442

443
            return isComponentTypeDifferent;
463✔
444

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

450
    /**
451
     * 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
452
     * @param componentKey key getting a component from `this.components`
453
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
454
     */
455
    private addComponentReferenceType(componentKey: string, componentName: string) {
456
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
471✔
457
        if (!symbolName) {
471✔
458
            return;
7✔
459
        }
460
        const components = this.components[componentKey] || [];
464!
461
        // Remove any existing symbols that match
462
        this.componentsTable.removeSymbol(symbolName);
464✔
463
        // There is a component that can be added - use it.
464
        if (components.length > 0) {
464✔
465

466
            const componentRefType = new ReferenceType(symbolName, symbolName, SymbolTypeFlag.typetime, () => this.componentsTable);
1,709✔
467
            if (componentRefType) {
463!
468
                this.componentsTable.addSymbol(symbolName, {}, componentRefType, SymbolTypeFlag.typetime);
463✔
469
            }
470
        }
471
    }
472

473
    /**
474
     * re-attach the dependency graph with a new key for any component who changed
475
     * their position in their own named array (only matters when there are multiple
476
     * components with the same name)
477
     */
478
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
479
        //reattach every dependency graph
480
        for (let i = 0; i < components.length; i++) {
418✔
481
            const { file, scope } = components[i];
411✔
482

483
            //attach (or re-attach) the dependencyGraph for every component whose position changed
484
            if (file.dependencyGraphIndex !== i) {
411✔
485
                file.dependencyGraphIndex = i;
407✔
486
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
407✔
487
                file.attachDependencyGraph(this.dependencyGraph);
407✔
488
                scope.attachDependencyGraph(this.dependencyGraph);
407✔
489
            }
490
        }
491
    }
492

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

510
    /**
511
     * Get the list of errors for the entire program.
512
     */
513
    public getDiagnostics() {
514
        return this.diagnostics.getDiagnostics();
1,143✔
515
    }
516

517
    /**
518
     * Determine if the specified file is loaded in this program right now.
519
     * @param filePath the absolute or relative path to the file
520
     * @param normalizePath should the provided path be normalized before use
521
     */
522
    public hasFile(filePath: string, normalizePath = true) {
2,573✔
523
        return !!this.getFile(filePath, normalizePath);
2,573✔
524
    }
525

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

541
    /**
542
     * Return all scopes
543
     */
544
    public getScopes() {
545
        return Object.values(this.scopes);
10✔
546
    }
547

548
    /**
549
     * Find the scope for the specified component
550
     */
551
    public getComponentScope(componentName: string) {
552
        return this.getComponent(componentName)?.scope;
865✔
553
    }
554

555
    /**
556
     * Update internal maps with this file reference
557
     */
558
    private assignFile<T extends BscFile = BscFile>(file: T) {
559
        const fileAddEvent: BeforeFileAddEvent = {
2,390✔
560
            file: file,
561
            program: this
562
        };
563

564
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,390✔
565

566
        this.files[file.srcPath.toLowerCase()] = file;
2,390✔
567
        this.destMap.set(file.destPath.toLowerCase(), file);
2,390✔
568

569
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,390✔
570

571
        return file;
2,390✔
572
    }
573

574
    /**
575
     * Remove this file from internal maps
576
     */
577
    private unassignFile<T extends BscFile = BscFile>(file: T) {
578
        delete this.files[file.srcPath.toLowerCase()];
156✔
579
        this.destMap.delete(file.destPath.toLowerCase());
156✔
580
        return file;
156✔
581
    }
582

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

600
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
2,386✔
601
            //if the file is already loaded, remove it
602
            if (this.hasFile(srcPath)) {
2,386✔
603
                this.removeFile(srcPath, true, true);
140✔
604
            }
605

606
            const data = new LazyFileData(fileData);
2,386✔
607

608
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,386✔
609

610
            this.plugins.emit('beforeProvideFile', event);
2,386✔
611
            this.plugins.emit('provideFile', event);
2,386✔
612
            this.plugins.emit('afterProvideFile', event);
2,386✔
613

614
            //if no files were provided, create a AssetFile to represent it.
615
            if (event.files.length === 0) {
2,386✔
616
                event.files.push(
18✔
617
                    this.fileFactory.AssetFile({
618
                        srcPath: event.srcPath,
619
                        destPath: event.destPath,
620
                        pkgPath: event.destPath,
621
                        data: data
622
                    })
623
                );
624
            }
625

626
            //find the file instance for the srcPath that triggered this action.
627
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
2,386✔
628

629
            if (!primaryFile) {
2,386!
UNCOV
630
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
631
                    type: x.type,
632
                    srcPath: x.srcPath,
633
                    destPath: x.destPath
634
                })))}`);
635
            }
636

637
            //link the virtual files to the primary file
638
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
2,386!
639

640
            for (const file of event.files) {
2,386✔
641
                file.srcPath = s(file.srcPath);
2,390✔
642
                if (file.destPath) {
2,390!
643
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
2,390✔
644
                }
645
                if (file.pkgPath) {
2,390✔
646
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
2,386✔
647
                } else {
648
                    file.pkgPath = file.destPath;
4✔
649
                }
650
                file.excludeFromOutput = file.excludeFromOutput === true;
2,390✔
651

652
                //set the dependencyGraph key for every file to its destPath
653
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,390✔
654

655
                this.assignFile(file);
2,390✔
656

657
                //register a callback anytime this file's dependencies change
658
                if (typeof file.onDependenciesChanged === 'function') {
2,390✔
659
                    file.disposables ??= [];
2,364!
660
                    file.disposables.push(
2,364✔
661
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
662
                    );
663
                }
664

665
                //register this file (and its dependencies) with the dependency graph
666
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
2,390✔
667

668
                //if this is a `source` file, add it to the source scope's dependency list
669
                if (this.isSourceBrsFile(file)) {
2,390✔
670
                    this.createSourceScope();
1,603✔
671
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
1,603✔
672
                }
673

674
                //if this is an xml file in the components folder, register it as a component
675
                if (this.isComponentsXmlFile(file)) {
2,390✔
676
                    //create a new scope for this xml file
677
                    let scope = new XmlScope(file, this);
405✔
678
                    this.addScope(scope);
405✔
679

680
                    //register this componet now that we have parsed it and know its component name
681
                    this.registerComponent(file, scope);
405✔
682

683
                    //notify plugins that the scope is created and the component is registered
684
                    this.plugins.emit('afterScopeCreate', {
405✔
685
                        program: this,
686
                        scope: scope
687
                    });
688
                }
689
            }
690

691
            return primaryFile;
2,386✔
692
        });
693
        return file as T;
2,386✔
694
    }
695

696
    /**
697
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
698
     * @param fileParam an object representing file paths
699
     * @param rootDir must be a pre-normalized path
700
     */
701
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
702
        let srcPath: string | undefined;
703
        let destPath: string | undefined;
704

705
        assert.ok(fileParam, 'fileParam is required');
2,545✔
706

707
        //lift the path vars from the incoming param
708
        if (typeof fileParam === 'string') {
2,545✔
709
            fileParam = this.removePkgPrefix(fileParam);
2,198✔
710
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,198✔
711
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,198✔
712
        } else {
713
            let param: any = fileParam;
347✔
714

715
            if (param.src) {
347✔
716
                srcPath = s`${param.src}`;
346✔
717
            }
718
            if (param.srcPath) {
347!
UNCOV
719
                srcPath = s`${param.srcPath}`;
×
720
            }
721
            if (param.dest) {
347✔
722
                destPath = s`${this.removePkgPrefix(param.dest)}`;
346✔
723
            }
724
            if (param.pkgPath) {
347!
UNCOV
725
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
726
            }
727
        }
728

729
        //if there's no srcPath, use the destPath to build an absolute srcPath
730
        if (!srcPath) {
2,545✔
731
            srcPath = s`${rootDir}/${destPath}`;
1✔
732
        }
733
        //coerce srcPath to an absolute path
734
        if (!path.isAbsolute(srcPath)) {
2,545✔
735
            srcPath = util.standardizePath(srcPath);
1✔
736
        }
737

738
        //if destPath isn't set, compute it from the other paths
739
        if (!destPath) {
2,545✔
740
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
741
        }
742

743
        assert.ok(srcPath, 'fileEntry.src is required');
2,545✔
744
        assert.ok(destPath, 'fileEntry.dest is required');
2,545✔
745

746
        return {
2,545✔
747
            srcPath: srcPath,
748
            //remove leading slash
749
            destPath: destPath.replace(/^[\/\\]+/, '')
750
        };
751
    }
752

753
    /**
754
     * Remove any leading `pkg:/` found in the path
755
     */
756
    private removePkgPrefix(path: string) {
757
        return path.replace(/^pkg:\//i, '');
2,544✔
758
    }
759

760
    /**
761
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
762
     */
763
    private isSourceBrsFile(file: BscFile) {
764
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
2,546✔
765
    }
766

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

774
    /**
775
     * Ensure source scope is created.
776
     * Note: automatically called internally, and no-op if it exists already.
777
     */
778
    public createSourceScope() {
779
        if (!this.scopes.source) {
2,362✔
780
            const sourceScope = new Scope('source', this, 'scope:source');
1,559✔
781
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,559✔
782
            this.addScope(sourceScope);
1,559✔
783
            this.plugins.emit('afterScopeCreate', {
1,559✔
784
                program: this,
785
                scope: sourceScope
786
            });
787
        }
788
    }
789

790
    /**
791
     * Remove a set of files from the program
792
     * @param srcPaths can be an array of srcPath or destPath strings
793
     * @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
794
     */
795
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
796
        for (let srcPath of srcPaths) {
1✔
797
            this.removeFile(srcPath, normalizePath);
1✔
798
        }
799
    }
800

801
    /**
802
     * Remove a file from the program
803
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
804
     * @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
805
     */
806
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
27✔
807
        this.logger.debug('Program.removeFile()', filePath);
154✔
808
        const paths = this.getPaths(filePath, this.options.rootDir);
154✔
809

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

813
        for (const file of files) {
154✔
814
            //if a file has already been removed, nothing more needs to be done here
815
            if (!file || !this.hasFile(file.srcPath)) {
157✔
816
                continue;
1✔
817
            }
818
            this.diagnostics.clearForFile(file.srcPath);
156✔
819

820
            const event: BeforeFileRemoveEvent = { file: file, program: this };
156✔
821
            this.plugins.emit('beforeFileRemove', event);
156✔
822

823
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
824
            let scope = this.scopes[file.destPath];
156✔
825
            if (scope) {
156✔
826
                const scopeDisposeEvent = {
13✔
827
                    program: this,
828
                    scope: scope
829
                };
830
                this.plugins.emit('beforeScopeDispose', scopeDisposeEvent);
13✔
831
                this.plugins.emit('onScopeDispose', scopeDisposeEvent);
13✔
832
                scope.dispose();
13✔
833
                //notify dependencies of this scope that it has been removed
834
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
13✔
835
                this.removeScope(this.scopes[file.destPath]);
13✔
836
                this.plugins.emit('afterScopeDispose', scopeDisposeEvent);
13✔
837
            }
838
            //remove the file from the program
839
            this.unassignFile(file);
156✔
840

841
            this.dependencyGraph.remove(file.dependencyGraphKey);
156✔
842

843
            //if this is a pkg:/source file, notify the `source` scope that it has changed
844
            if (this.isSourceBrsFile(file)) {
156✔
845
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
127✔
846
            }
847
            if (isBrsFile(file)) {
156✔
848
                if (!keepSymbolInformation) {
137✔
849
                    this.fileSymbolInformation.delete(file.pkgPath);
8✔
850
                }
851
                this.crossScopeValidation.clearResolutionsForFile(file);
137✔
852
            }
853

854
            //if this is a component, remove it from our components map
855
            if (isXmlFile(file)) {
156✔
856
                this.unregisterComponent(file);
13✔
857
            }
858
            //dispose any disposable things on the file
859
            for (const disposable of file?.disposables ?? []) {
156!
860
                disposable();
150✔
861
            }
862
            //dispose file
863
            file?.dispose?.();
156!
864

865
            this.plugins.emit('afterFileRemove', event);
156✔
866
        }
867
    }
868

869
    public crossScopeValidation = new CrossScopeValidator(this);
1,795✔
870

871
    private isFirstValidation = true;
1,795✔
872

873
    /**
874
     * Traverse the entire project, and validate all scopes
875
     */
876
    public validate() {
877
        this.logger.time(LogLevel.log, ['Validating project'], () => {
1,345✔
878
            this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,345✔
879
            const programValidateEvent = {
1,345✔
880
                program: this
881
            };
882
            this.plugins.emit('beforeProgramValidate', programValidateEvent);
1,345✔
883
            this.plugins.emit('onProgramValidate', programValidateEvent);
1,345✔
884

885
            const metrics = {
1,345✔
886
                filesChanged: 0,
887
                filesValidated: 0,
888
                fileValidationTime: '',
889
                crossScopeValidationTime: '',
890
                scopesValidated: 0,
891
                totalLinkTime: '',
892
                totalScopeValidationTime: '',
893
                componentValidationTime: '',
894
                changedSymbolsTime: ''
895
            };
896

897
            const validationStopwatch = new Stopwatch();
1,345✔
898
            //validate every file
899
            const brsFilesValidated: BrsFile[] = [];
1,345✔
900
            const xmlFilesValidated: XmlFile[] = [];
1,345✔
901

902
            const afterValidateFiles: BscFile[] = [];
1,345✔
903
            const sortedFiles = Object.values(this.files).sort(firstBy(x => x.srcPath));
3,680✔
904
            // Create reference component types for any component that changes
905
            this.logger.time(LogLevel.info, ['Prebuild component types'], () => {
1,345✔
906
                for (const file of sortedFiles) {
1,345✔
907
                    if (isXmlFile(file)) {
2,291✔
908
                        this.addDeferredComponentTypeSymbolCreation(file);
473✔
909
                    } else if (isBrsFile(file)) {
1,818✔
910
                        for (const scope of this.getScopesForFile(file)) {
1,807✔
911
                            if (isXmlScope(scope)) {
2,113✔
912
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
757✔
913
                            }
914
                        }
915
                    }
916
                }
917

918
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,345✔
919
                    this.addComponentReferenceType(componentKey, componentName);
471✔
920
                }
921
            });
922

923

924
            metrics.fileValidationTime = validationStopwatch.getDurationTextFor(() => {
1,345✔
925
                //sort files by path so we get consistent results
926
                for (const file of sortedFiles) {
1,345✔
927
                    //for every unvalidated file, validate it
928
                    if (!file.isValidated) {
2,291✔
929
                        const validateFileEvent = {
1,951✔
930
                            program: this,
931
                            file: file
932
                        };
933
                        this.plugins.emit('beforeFileValidate', validateFileEvent);
1,951✔
934
                        //emit an event to allow plugins to contribute to the file validation process
935
                        this.plugins.emit('onFileValidate', validateFileEvent);
1,951✔
936
                        file.isValidated = true;
1,951✔
937
                        if (isBrsFile(file)) {
1,951✔
938
                            brsFilesValidated.push(file);
1,619✔
939
                        } else if (isXmlFile(file)) {
332!
940
                            xmlFilesValidated.push(file);
332✔
941
                        }
942
                        afterValidateFiles.push(file);
1,951✔
943
                    }
944
                }
945
                // AfterFileValidate is after all files have been validated
946
                for (const file of afterValidateFiles) {
1,345✔
947
                    const validateFileEvent = {
1,951✔
948
                        program: this,
949
                        file: file
950
                    };
951
                    this.plugins.emit('afterFileValidate', validateFileEvent);
1,951✔
952
                }
953
            }).durationText;
954

955
            metrics.filesChanged = afterValidateFiles.length;
1,345✔
956

957
            const changedComponentTypes: string[] = [];
1,345✔
958

959
            // Build component types for any component that changes
960
            this.logger.time(LogLevel.info, ['Build component types'], () => {
1,345✔
961
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
1,345✔
962
                    if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
471✔
963
                        changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
463✔
964
                    }
965
                }
966
                this.componentSymbolsToUpdate.clear();
1,345✔
967
            });
968

969
            // get set of changed symbols
970
            const changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,345✔
971
            metrics.changedSymbolsTime = validationStopwatch.getDurationTextFor(() => {
1,345✔
972

973
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
1,345!
974
                    if (isBrsFile(f)) {
1,951✔
975
                        return f.providedSymbols.changes;
1,619✔
976
                    }
977
                    return null;
332✔
978
                }).filter(x => x);
1,951✔
979

980

981
                // update the map of typetime dependencies
982
                for (const file of brsFilesValidated) {
1,345✔
983
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
1,619✔
984
                        // clear existing dependencies
985
                        for (const values of this.symbolDependencies.values()) {
633✔
986
                            values.delete(symbolName);
59✔
987
                        }
988

989
                        // map types to the set of types that depend upon them
990
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
633!
991
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
166✔
992
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
166✔
993
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
146✔
994
                            }
995
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
166✔
996
                            symbolsDependentUpon.add(symbolName);
166✔
997
                        }
998
                    }
999
                }
1000

1001
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,345✔
1002
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,238✔
1003
                    const changedSymbolSet = new Set<string>();
2,690✔
1004
                    for (const changeSet of changedSymbolsSetArr) {
2,690✔
1005
                        for (const change of changeSet) {
3,238✔
1006
                            changedSymbolSet.add(change);
3,304✔
1007
                        }
1008
                    }
1009
                    changedSymbols.set(flag, changedSymbolSet);
2,690✔
1010
                }
1011

1012
                // update changed symbol set with any changed component
1013
                for (const changedComponentType of changedComponentTypes) {
1,345✔
1014
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
463✔
1015
                }
1016

1017
                // Add any additional types that depend on a changed type
1018
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1019
                const dependentTypesChanged = new Set<string>();
1,345✔
1020
                let foundDependentTypes = false;
1,345✔
1021
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
1,345✔
1022
                do {
1,345✔
1023
                    foundDependentTypes = false;
1,349✔
1024
                    for (const changedSymbol of changedTypeSymbols) {
1,349✔
1025
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,094✔
1026
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,094✔
1027
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
168✔
1028
                                foundDependentTypes = true;
4✔
1029
                                dependentTypesChanged.add(symbolName);
4✔
1030
                            }
1031
                        }
1032
                    }
1033
                } while (foundDependentTypes);
1034

1035
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedTypeSymbols, ...dependentTypesChanged]));
1,345✔
1036
            }).durationText;
1037

1038
            const filesToBeValidatedInScopeContext = new Set<BscFile>(afterValidateFiles);
1,345✔
1039

1040
            metrics.crossScopeValidationTime = validationStopwatch.getDurationTextFor(() => {
1,345✔
1041
                const scopesToCheck = this.getScopesForCrossScopeValidation(changedComponentTypes.length > 0);
1,345✔
1042
                this.crossScopeValidation.buildComponentsMap();
1,345✔
1043
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,345✔
1044
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,345✔
1045
                for (const file of filesToRevalidate) {
1,345✔
1046
                    filesToBeValidatedInScopeContext.add(file);
406✔
1047
                }
1048
            }).durationText;
1049

1050
            metrics.filesValidated = filesToBeValidatedInScopeContext.size;
1,345✔
1051

1052
            let linkTime = 0;
1,345✔
1053
            let validationTime = 0;
1,345✔
1054
            let scopesValidated = 0;
1,345✔
1055
            let changedFiles = new Set<BscFile>(afterValidateFiles);
1,345✔
1056
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
1,345✔
1057
                //sort the scope names so we get consistent results
1058
                const scopeNames = this.getSortedScopeNames();
1,345✔
1059
                for (const file of filesToBeValidatedInScopeContext) {
1,345✔
1060
                    if (isBrsFile(file)) {
2,078✔
1061
                        file.validationSegmenter.unValidateAllSegments();
1,746✔
1062
                        for (const scope of this.getScopesForFile(file)) {
1,746✔
1063
                            scope.invalidate();
2,042✔
1064
                        }
1065
                    }
1066
                }
1067
                for (let scopeName of scopeNames) {
1,345✔
1068
                    let scope = this.scopes[scopeName];
3,067✔
1069
                    const scopeValidated = scope.validate({
3,067✔
1070
                        filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1071
                        changedSymbols: changedSymbols,
1072
                        changedFiles: changedFiles,
1073
                        initialValidation: this.isFirstValidation
1074
                    });
1075
                    if (scopeValidated) {
3,067✔
1076
                        scopesValidated++;
1,671✔
1077
                    }
1078
                    linkTime += scope.validationMetrics.linkTime;
3,067✔
1079
                    validationTime += scope.validationMetrics.validationTime;
3,067✔
1080
                }
1081
            });
1082
            metrics.scopesValidated = scopesValidated;
1,345✔
1083
            validationStopwatch.totalMilliseconds = linkTime;
1,345✔
1084
            metrics.totalLinkTime = validationStopwatch.getDurationText();
1,345✔
1085

1086
            validationStopwatch.totalMilliseconds = validationTime;
1,345✔
1087
            metrics.totalScopeValidationTime = validationStopwatch.getDurationText();
1,345✔
1088

1089
            metrics.componentValidationTime = validationStopwatch.getDurationTextFor(() => {
1,345✔
1090
                this.detectDuplicateComponentNames();
1,345✔
1091
            }).durationText;
1092

1093
            this.logValidationMetrics(metrics);
1,345✔
1094

1095
            this.isFirstValidation = false;
1,345✔
1096

1097
            this.plugins.emit('afterProgramValidate', programValidateEvent);
1,345✔
1098
        });
1099
    }
1100

1101
    // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
1102
    private logValidationMetrics(metrics: { [key: string]: number | string }) {
1103
        let logs = [] as string[];
1,345✔
1104
        for (const key in metrics) {
1,345✔
1105
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
12,105✔
1106
        }
1107
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
1,345✔
1108
    }
1109

1110
    private getScopesForCrossScopeValidation(someComponentTypeChanged = false) {
×
1111
        const scopesForCrossScopeValidation = [];
1,345✔
1112
        for (let scopeName of this.getSortedScopeNames()) {
1,345✔
1113
            let scope = this.scopes[scopeName];
3,067✔
1114
            if (this.globalScope !== scope && (someComponentTypeChanged || !scope.isValidated)) {
3,067✔
1115
                scopesForCrossScopeValidation.push(scope);
1,712✔
1116
            }
1117
        }
1118
        return scopesForCrossScopeValidation;
1,345✔
1119
    }
1120

1121
    /**
1122
     * Flag all duplicate component names
1123
     */
1124
    private detectDuplicateComponentNames() {
1125
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,345✔
1126
            const file = this.files[filePath];
2,291✔
1127
            //if this is an XmlFile, and it has a valid `componentName` property
1128
            if (isXmlFile(file) && file.componentName?.text) {
2,291✔
1129
                let lowerName = file.componentName.text.toLowerCase();
466✔
1130
                if (!map[lowerName]) {
466✔
1131
                    map[lowerName] = [];
463✔
1132
                }
1133
                map[lowerName].push(file);
466✔
1134
            }
1135
            return map;
2,291✔
1136
        }, {});
1137

1138
        for (let name in componentsByName) {
1,345✔
1139
            const xmlFiles = componentsByName[name];
463✔
1140
            //add diagnostics for every duplicate component with this name
1141
            if (xmlFiles.length > 1) {
463✔
1142
                for (let xmlFile of xmlFiles) {
3✔
1143
                    const { componentName } = xmlFile;
6✔
1144
                    this.diagnostics.register({
6✔
1145
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1146
                        location: xmlFile.componentName.location,
1147
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1148
                            return {
6✔
1149
                                location: x.componentName.location,
1150
                                message: 'Also defined here'
1151
                            };
1152
                        })
1153
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1154
                }
1155
            }
1156
        }
1157
    }
1158

1159
    /**
1160
     * Get the files for a list of filePaths
1161
     * @param filePaths can be an array of srcPath or a destPath strings
1162
     * @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
1163
     */
1164
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1165
        return filePaths
29✔
1166
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1167
            .filter(file => file !== undefined) as T[];
39✔
1168
    }
1169

1170
    /**
1171
     * Get the file at the given path
1172
     * @param filePath can be a srcPath or a destPath
1173
     * @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
1174
     */
1175
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
17,494✔
1176
        if (typeof filePath !== 'string') {
23,999✔
1177
            return undefined;
3,385✔
1178
            //is the path absolute (or the `virtual:` prefix)
1179
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
20,614✔
1180
            return this.files[
4,598✔
1181
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
4,598!
1182
            ] as T;
1183
        } else if (util.isUriLike(filePath)) {
16,016✔
1184
            const path = URI.parse(filePath).fsPath;
691✔
1185
            return this.files[
691✔
1186
                (normalizePath ? util.standardizePath(path) : path).toLowerCase()
691!
1187
            ] as T;
1188
        } else {
1189
            return this.destMap.get(
15,325✔
1190
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
15,325✔
1191
            ) as T;
1192
        }
1193
    }
1194

1195
    private sortedScopeNames: string[] = undefined;
1,795✔
1196

1197
    /**
1198
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1199
     */
1200
    private getSortedScopeNames() {
1201
        if (!this.sortedScopeNames) {
10,943✔
1202
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,298✔
1203
                if (a === 'global') {
1,870!
UNCOV
1204
                    return -1;
×
1205
                } else if (b === 'global') {
1,870✔
1206
                    return 1;
1,280✔
1207
                }
1208
                if (a === 'source') {
590✔
1209
                    return -1;
27✔
1210
                } else if (b === 'source') {
563✔
1211
                    return 1;
131✔
1212
                }
1213
                if (a < b) {
432✔
1214
                    return -1;
181✔
1215
                } else if (b < a) {
251!
1216
                    return 1;
251✔
1217
                }
UNCOV
1218
                return 0;
×
1219
            });
1220
        }
1221
        return this.sortedScopeNames;
10,943✔
1222
    }
1223

1224
    /**
1225
     * Get a list of all scopes the file is loaded into
1226
     * @param file the file
1227
     */
1228
    public getScopesForFile(file: BscFile | string) {
1229
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
4,273✔
1230

1231
        let result = [] as Scope[];
4,273✔
1232
        if (resolvedFile) {
4,273✔
1233
            const scopeKeys = this.getSortedScopeNames();
4,272✔
1234
            for (let key of scopeKeys) {
4,272✔
1235
                let scope = this.scopes[key];
49,899✔
1236

1237
                if (scope.hasFile(resolvedFile)) {
49,899✔
1238
                    result.push(scope);
4,888✔
1239
                }
1240
            }
1241
        }
1242
        return result;
4,273✔
1243
    }
1244

1245
    /**
1246
     * Get the first found scope for a file.
1247
     */
1248
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1249
        const scopeKeys = this.getSortedScopeNames();
3,981✔
1250
        for (let key of scopeKeys) {
3,981✔
1251
            let scope = this.scopes[key];
18,350✔
1252

1253
            if (scope.hasFile(file)) {
18,350✔
1254
                return scope;
2,906✔
1255
            }
1256
        }
1257
    }
1258

1259
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1260
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1261
        const filesSearched = new Set<BrsFile>();
39✔
1262
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1263
        let lowerName = name?.toLowerCase();
39!
1264

1265
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1266
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1267
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1268
                if (!results.has(statement)) {
36!
1269
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1270
                }
1271
            }
1272
        }
1273

1274
        //look through all files in scope for matches
1275
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1276
            for (const file of scope.getAllFiles()) {
39✔
1277
                //skip non-brs files, or files we've already processed
1278
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1279
                    continue;
3✔
1280
                }
1281
                filesSearched.add(file);
42✔
1282

1283
                file.ast.walk(createVisitor({
42✔
1284
                    FunctionStatement: (statement: FunctionStatement) => {
1285
                        addToResults(statement, file);
95✔
1286
                    },
1287
                    MethodStatement: (statement: MethodStatement) => {
1288
                        addToResults(statement, file);
3✔
1289
                    }
1290
                }), {
1291
                    walkMode: WalkMode.visitStatements
1292
                });
1293
            }
1294
        }
1295
        return [...results.values()];
39✔
1296
    }
1297

1298
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1299
        let results = new Map<Statement, FileLink<FunctionStatement>>();
8✔
1300
        const filesSearched = new Set<BrsFile>();
8✔
1301

1302
        //get all function names for the xml file and parents
1303
        let funcNames = new Set<string>();
8✔
1304
        let currentScope = scope;
8✔
1305
        while (isXmlScope(currentScope)) {
8✔
1306
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
14✔
1307
                if (!filterName || name === filterName) {
14!
1308
                    funcNames.add(name);
14✔
1309
                }
1310
            }
1311
            currentScope = currentScope.getParentScope() as XmlScope;
10✔
1312
        }
1313

1314
        //look through all files in scope for matches
1315
        for (const file of scope.getOwnFiles()) {
8✔
1316
            //skip non-brs files, or files we've already processed
1317
            if (!isBrsFile(file) || filesSearched.has(file)) {
16✔
1318
                continue;
8✔
1319
            }
1320
            filesSearched.add(file);
8✔
1321

1322
            file.ast.walk(createVisitor({
8✔
1323
                FunctionStatement: (statement: FunctionStatement) => {
1324
                    if (funcNames.has(statement.tokens.name.text)) {
13!
1325
                        if (!results.has(statement)) {
13!
1326
                            results.set(statement, { item: statement, file: file });
13✔
1327
                        }
1328
                    }
1329
                }
1330
            }), {
1331
                walkMode: WalkMode.visitStatements
1332
            });
1333
        }
1334
        return [...results.values()];
8✔
1335
    }
1336

1337
    /**
1338
     * Find all available completion items at the given position
1339
     * @param filePath can be a srcPath or a destPath
1340
     * @param position the position (line & column) where completions should be found
1341
     */
1342
    public getCompletions(filePath: string, position: Position) {
1343
        let file = this.getFile(filePath);
117✔
1344
        if (!file) {
117!
UNCOV
1345
            return [];
×
1346
        }
1347

1348
        //find the scopes for this file
1349
        let scopes = this.getScopesForFile(file);
117✔
1350

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

1354
        const event: ProvideCompletionsEvent = {
117✔
1355
            program: this,
1356
            file: file,
1357
            scopes: scopes,
1358
            position: position,
1359
            completions: []
1360
        };
1361

1362
        this.plugins.emit('beforeProvideCompletions', event);
117✔
1363

1364
        this.plugins.emit('provideCompletions', event);
117✔
1365

1366
        this.plugins.emit('afterProvideCompletions', event);
117✔
1367

1368
        return event.completions;
117✔
1369
    }
1370

1371
    /**
1372
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1373
     */
1374
    public getWorkspaceSymbols() {
1375
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1376
            program: this,
1377
            workspaceSymbols: []
1378
        };
1379
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1380
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1381
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1382
        return event.workspaceSymbols;
22✔
1383
    }
1384

1385
    /**
1386
     * Given a position in a file, if the position is sitting on some type of identifier,
1387
     * go to the definition of that identifier (where this thing was first defined)
1388
     */
1389
    public getDefinition(srcPath: string, position: Position): Location[] {
1390
        let file = this.getFile(srcPath);
18✔
1391
        if (!file) {
18!
UNCOV
1392
            return [];
×
1393
        }
1394

1395
        const event: ProvideDefinitionEvent = {
18✔
1396
            program: this,
1397
            file: file,
1398
            position: position,
1399
            definitions: []
1400
        };
1401

1402
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1403
        this.plugins.emit('provideDefinition', event);
18✔
1404
        this.plugins.emit('afterProvideDefinition', event);
18✔
1405
        return event.definitions;
18✔
1406
    }
1407

1408
    /**
1409
     * Get hover information for a file and position
1410
     */
1411
    public getHover(srcPath: string, position: Position): Hover[] {
1412
        let file = this.getFile(srcPath);
68✔
1413
        let result: Hover[];
1414
        if (file) {
68!
1415
            const event = {
68✔
1416
                program: this,
1417
                file: file,
1418
                position: position,
1419
                scopes: this.getScopesForFile(file),
1420
                hovers: []
1421
            } as ProvideHoverEvent;
1422
            this.plugins.emit('beforeProvideHover', event);
68✔
1423
            this.plugins.emit('provideHover', event);
68✔
1424
            this.plugins.emit('afterProvideHover', event);
68✔
1425
            result = event.hovers;
68✔
1426
        }
1427

1428
        return result ?? [];
68!
1429
    }
1430

1431
    /**
1432
     * Get full list of document symbols for a file
1433
     * @param srcPath path to the file
1434
     */
1435
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1436
        let file = this.getFile(srcPath);
18✔
1437
        if (file) {
18!
1438
            const event: ProvideDocumentSymbolsEvent = {
18✔
1439
                program: this,
1440
                file: file,
1441
                documentSymbols: []
1442
            };
1443
            this.plugins.emit('beforeProvideDocumentSymbols', event);
18✔
1444
            this.plugins.emit('provideDocumentSymbols', event);
18✔
1445
            this.plugins.emit('afterProvideDocumentSymbols', event);
18✔
1446
            return event.documentSymbols;
18✔
1447
        } else {
UNCOV
1448
            return undefined;
×
1449
        }
1450
    }
1451

1452
    /**
1453
     * Compute code actions for the given file and range
1454
     */
1455
    public getCodeActions(srcPath: string, range: Range) {
1456
        const codeActions = [] as CodeAction[];
13✔
1457
        const file = this.getFile(srcPath);
13✔
1458
        if (file) {
13✔
1459
            const fileUri = util.pathToUri(file?.srcPath);
12!
1460
            const diagnostics = this
12✔
1461
                //get all current diagnostics (filtered by diagnostic filters)
1462
                .getDiagnostics()
1463
                //only keep diagnostics related to this file
1464
                .filter(x => x.location?.uri === fileUri)
25✔
1465
                //only keep diagnostics that touch this range
1466
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
12✔
1467

1468
            const scopes = this.getScopesForFile(file);
12✔
1469

1470
            this.plugins.emit('onGetCodeActions', {
12✔
1471
                program: this,
1472
                file: file,
1473
                range: range,
1474
                diagnostics: diagnostics,
1475
                scopes: scopes,
1476
                codeActions: codeActions
1477
            });
1478
        }
1479
        return codeActions;
13✔
1480
    }
1481

1482
    /**
1483
     * Get semantic tokens for the specified file
1484
     */
1485
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1486
        const file = this.getFile(srcPath);
24✔
1487
        if (file) {
24!
1488
            const result = [] as SemanticToken[];
24✔
1489
            this.plugins.emit('onGetSemanticTokens', {
24✔
1490
                program: this,
1491
                file: file,
1492
                scopes: this.getScopesForFile(file),
1493
                semanticTokens: result
1494
            });
1495
            return result;
24✔
1496
        }
1497
    }
1498

1499
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1500
        let file: BrsFile = this.getFile(filepath);
185✔
1501
        if (!file || !isBrsFile(file)) {
185✔
1502
            return [];
3✔
1503
        }
1504
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1505
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1506
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1507
    }
1508

1509
    public getReferences(srcPath: string, position: Position): Location[] {
1510
        //find the file
1511
        let file = this.getFile(srcPath);
4✔
1512

1513
        const event: ProvideReferencesEvent = {
4✔
1514
            program: this,
1515
            file: file,
1516
            position: position,
1517
            references: []
1518
        };
1519

1520
        this.plugins.emit('beforeProvideReferences', event);
4✔
1521
        this.plugins.emit('provideReferences', event);
4✔
1522
        this.plugins.emit('afterProvideReferences', event);
4✔
1523

1524
        return event.references;
4✔
1525
    }
1526

1527
    /**
1528
     * Transpile a single file and get the result as a string.
1529
     * This does not write anything to the file system.
1530
     *
1531
     * This should only be called by `LanguageServer`.
1532
     * Internal usage should call `_getTranspiledFileContents` instead.
1533
     * @param filePath can be a srcPath or a destPath
1534
     */
1535
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1536
        const file = this.getFile(filePath);
306✔
1537

1538
        return this.getTranspiledFileContentsPipeline.run(async () => {
306✔
1539

1540
            const result = {
306✔
1541
                destPath: file.destPath,
1542
                pkgPath: file.pkgPath,
1543
                srcPath: file.srcPath
1544
            } as FileTranspileResult;
1545

1546
            const expectedPkgPath = file.pkgPath.toLowerCase();
306✔
1547
            const expectedMapPath = `${expectedPkgPath}.map`;
306✔
1548
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
306✔
1549

1550
            //add a temporary plugin to tap into the file writing process
1551
            const plugin = this.plugins.addFirst({
306✔
1552
                name: 'getTranspiledFileContents',
1553
                beforeWriteFile: (event) => {
1554
                    const pkgPath = event.file.pkgPath.toLowerCase();
968✔
1555
                    switch (pkgPath) {
968✔
1556
                        //this is the actual transpiled file
1557
                        case expectedPkgPath:
968✔
1558
                            result.code = event.file.data.toString();
306✔
1559
                            break;
306✔
1560
                        //this is the sourcemap
1561
                        case expectedMapPath:
1562
                            result.map = event.file.data.toString();
170✔
1563
                            break;
170✔
1564
                        //this is the typedef
1565
                        case expectedTypedefPkgPath:
1566
                            result.typedef = event.file.data.toString();
8✔
1567
                            break;
8✔
1568
                        default:
1569
                        //no idea what this file is. just ignore it
1570
                    }
1571
                    //mark every file as processed so it they don't get written to the output directory
1572
                    event.processedFiles.add(event.file);
968✔
1573
                }
1574
            });
1575

1576
            try {
306✔
1577
                //now that the plugin has been registered, run the build with just this file
1578
                await this.build({
306✔
1579
                    files: [file]
1580
                });
1581
            } finally {
1582
                this.plugins.remove(plugin);
306✔
1583
            }
1584
            return result;
306✔
1585
        });
1586
    }
1587
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,795✔
1588

1589
    /**
1590
     * Get the absolute output path for a file
1591
     */
1592
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1593
        return s`${stagingDir}/${file.pkgPath}`;
1,783✔
1594
    }
1595

1596
    private getStagingDir(stagingDir?: string) {
1597
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
693✔
1598
        if (!result) {
693✔
1599
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
507✔
1600
        }
1601
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
693!
1602
        return result;
693✔
1603
    }
1604

1605
    /**
1606
     * Prepare the program for building
1607
     * @param files the list of files that should be prepared
1608
     */
1609
    private async prepare(files: BscFile[]) {
1610
        const programEvent: PrepareProgramEvent = {
347✔
1611
            program: this,
1612
            editor: this.editor,
1613
            files: files
1614
        };
1615

1616
        //assign an editor to every file
1617
        for (const file of programEvent.files) {
347✔
1618
            //if the file doesn't have an editor yet, assign one now
1619
            if (!file.editor) {
704✔
1620
                file.editor = new Editor();
657✔
1621
            }
1622
        }
1623

1624
        //sort the entries to make transpiling more deterministic
1625
        programEvent.files.sort((a, b) => {
347✔
1626
            if (a.pkgPath < b.pkgPath) {
372✔
1627
                return -1;
311✔
1628
            } else if (a.pkgPath > b.pkgPath) {
61!
1629
                return 1;
61✔
1630
            } else {
UNCOV
1631
                return 1;
×
1632
            }
1633
        });
1634

1635
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
347✔
1636
        await this.plugins.emitAsync('prepareProgram', programEvent);
347✔
1637

1638
        const stagingDir = this.getStagingDir();
347✔
1639

1640
        const entries: TranspileObj[] = [];
347✔
1641

1642
        for (const file of files) {
347✔
1643
            const scope = this.getFirstScopeForFile(file);
704✔
1644
            //link the symbol table for all the files in this scope
1645
            scope?.linkSymbolTable();
704✔
1646

1647
            //if the file doesn't have an editor yet, assign one now
1648
            if (!file.editor) {
704!
UNCOV
1649
                file.editor = new Editor();
×
1650
            }
1651
            const event = {
704✔
1652
                program: this,
1653
                file: file,
1654
                editor: file.editor,
1655
                scope: scope,
1656
                outputPath: this.getOutputPath(file, stagingDir)
1657
            } as PrepareFileEvent & { outputPath: string };
1658

1659
            await this.plugins.emitAsync('beforePrepareFile', event);
704✔
1660
            await this.plugins.emitAsync('prepareFile', event);
704✔
1661
            await this.plugins.emitAsync('afterPrepareFile', event);
704✔
1662

1663
            //TODO remove this in v1
1664
            entries.push(event);
704✔
1665

1666
            //unlink the symbolTable so the next loop iteration can link theirs
1667
            scope?.unlinkSymbolTable();
704✔
1668
        }
1669

1670
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
347✔
1671
        return files;
347✔
1672
    }
1673

1674
    /**
1675
     * Generate the contents of every file
1676
     */
1677
    private async serialize(files: BscFile[]) {
1678

1679
        const allFiles = new Map<BscFile, SerializedFile[]>();
346✔
1680

1681
        //exclude prunable files if that option is enabled
1682
        if (this.options.pruneEmptyCodeFiles === true) {
346✔
1683
            files = files.filter(x => x.canBePruned !== true);
9✔
1684
        }
1685

1686
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
346✔
1687
            program: this,
1688
            files: files,
1689
            result: allFiles
1690
        });
1691
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
346✔
1692

1693
        // serialize each file
1694
        for (const file of files) {
346✔
1695
            let scope = this.getFirstScopeForFile(file);
701✔
1696

1697
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1698
            if (!scope) {
701✔
1699
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
357✔
1700
                scope.getAllFiles = () => [file];
3,200✔
1701
                scope.getOwnFiles = scope.getAllFiles;
357✔
1702
            }
1703

1704
            //link the symbol table for all the files in this scope
1705
            scope?.linkSymbolTable();
701!
1706
            const event: SerializeFileEvent = {
701✔
1707
                program: this,
1708
                file: file,
1709
                scope: scope,
1710
                result: allFiles
1711
            };
1712
            await this.plugins.emitAsync('beforeSerializeFile', event);
701✔
1713
            await this.plugins.emitAsync('serializeFile', event);
701✔
1714
            await this.plugins.emitAsync('afterSerializeFile', event);
701✔
1715
            //unlink the symbolTable so the next loop iteration can link theirs
1716
            scope?.unlinkSymbolTable();
701!
1717
        }
1718

1719
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
346✔
1720

1721
        return allFiles;
346✔
1722
    }
1723

1724
    /**
1725
     * Write the entire project to disk
1726
     */
1727
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1728
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
346✔
1729
            program: this,
1730
            files: files,
1731
            stagingDir: stagingDir
1732
        });
1733
        //empty the staging directory
1734
        await fsExtra.emptyDir(stagingDir);
346✔
1735

1736
        const serializedFiles = [...files]
346✔
1737
            .map(([, serializedFiles]) => serializedFiles)
701✔
1738
            .flat();
1739

1740
        //write all the files to disk (asynchronously)
1741
        await Promise.all(
346✔
1742
            serializedFiles.map(async (file) => {
1743
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,079✔
1744
                    program: this,
1745
                    file: file,
1746
                    outputPath: this.getOutputPath(file, stagingDir),
1747
                    processedFiles: new Set<SerializedFile>()
1748
                });
1749

1750
                await this.plugins.emitAsync('writeFile', event);
1,079✔
1751

1752
                await this.plugins.emitAsync('afterWriteFile', event);
1,079✔
1753
            })
1754
        );
1755

1756
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
346✔
1757
    }
1758

1759
    private buildPipeline = new ActionPipeline();
1,795✔
1760

1761
    /**
1762
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1763
     * @param options the list of options used to build the program
1764
     */
1765
    public async build(options?: ProgramBuildOptions) {
1766
        //run a single build at a time
1767
        await this.buildPipeline.run(async () => {
346✔
1768
            const stagingDir = this.getStagingDir(options?.stagingDir);
346✔
1769

1770
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
346✔
1771
                program: this,
1772
                editor: this.editor,
1773
                files: options?.files ?? Object.values(this.files)
2,076✔
1774
            });
1775

1776
            //prepare the program (and files) for building
1777
            event.files = await this.prepare(event.files);
346✔
1778

1779
            //stage the entire program
1780
            const serializedFilesByFile = await this.serialize(event.files);
346✔
1781

1782
            await this.write(stagingDir, serializedFilesByFile);
346✔
1783

1784
            await this.plugins.emitAsync('afterBuildProgram', event);
346✔
1785

1786
            //undo all edits for the program
1787
            this.editor.undoAll();
346✔
1788
            //undo all edits for each file
1789
            for (const file of event.files) {
346✔
1790
                file.editor.undoAll();
702✔
1791
            }
1792
        });
1793
    }
1794

1795
    /**
1796
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1797
     */
1798
    public findFilesForFunction(functionName: string) {
1799
        const files = [] as BscFile[];
7✔
1800
        const lowerFunctionName = functionName.toLowerCase();
7✔
1801
        //find every file with this function defined
1802
        for (const file of Object.values(this.files)) {
7✔
1803
            if (isBrsFile(file)) {
25✔
1804
                //TODO handle namespace-relative function calls
1805
                //if the file has a function with this name
1806
                // eslint-disable-next-line @typescript-eslint/dot-notation
1807
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
1808
                    files.push(file);
2✔
1809
                }
1810
            }
1811
        }
1812
        return files;
7✔
1813
    }
1814

1815
    /**
1816
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1817
     */
1818
    public findFilesForClass(className: string) {
1819
        const files = [] as BscFile[];
7✔
1820
        const lowerClassName = className.toLowerCase();
7✔
1821
        //find every file with this class defined
1822
        for (const file of Object.values(this.files)) {
7✔
1823
            if (isBrsFile(file)) {
25✔
1824
                //TODO handle namespace-relative classes
1825
                //if the file has a function with this name
1826

1827
                // eslint-disable-next-line @typescript-eslint/dot-notation
1828
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
1829
                    files.push(file);
1✔
1830
                }
1831
            }
1832
        }
1833
        return files;
7✔
1834
    }
1835

1836
    public findFilesForNamespace(name: string) {
1837
        const files = [] as BscFile[];
7✔
1838
        const lowerName = name.toLowerCase();
7✔
1839
        //find every file with this class defined
1840
        for (const file of Object.values(this.files)) {
7✔
1841
            if (isBrsFile(file)) {
25✔
1842

1843
                // eslint-disable-next-line @typescript-eslint/dot-notation
1844
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
1845
                    const namespaceName = x.name.toLowerCase();
7✔
1846
                    return (
7✔
1847
                        //the namespace name matches exactly
1848
                        namespaceName === lowerName ||
9✔
1849
                        //the full namespace starts with the name (honoring the part boundary)
1850
                        namespaceName.startsWith(lowerName + '.')
1851
                    );
1852
                })) {
1853
                    files.push(file);
6✔
1854
                }
1855
            }
1856
        }
1857

1858
        return files;
7✔
1859
    }
1860

1861
    public findFilesForEnum(name: string) {
1862
        const files = [] as BscFile[];
8✔
1863
        const lowerName = name.toLowerCase();
8✔
1864
        //find every file with this enum defined
1865
        for (const file of Object.values(this.files)) {
8✔
1866
            if (isBrsFile(file)) {
26✔
1867
                // eslint-disable-next-line @typescript-eslint/dot-notation
1868
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
1869
                    files.push(file);
1✔
1870
                }
1871
            }
1872
        }
1873
        return files;
8✔
1874
    }
1875

1876
    private _manifest: Map<string, string>;
1877

1878
    /**
1879
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1880
     * @param parsedManifest The manifest map to read from and modify
1881
     */
1882
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1883
        // Lift the bs_consts defined in the manifest
1884
        let bsConsts = getBsConst(parsedManifest, false);
15✔
1885

1886
        // Override or delete any bs_consts defined in the bs config
1887
        for (const key in this.options?.manifest?.bs_const) {
15!
1888
            const value = this.options.manifest.bs_const[key];
3✔
1889
            if (value === null) {
3✔
1890
                bsConsts.delete(key);
1✔
1891
            } else {
1892
                bsConsts.set(key, value);
2✔
1893
            }
1894
        }
1895

1896
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1897
        let constString = '';
15✔
1898
        for (const [key, value] of bsConsts) {
15✔
1899
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1900
        }
1901

1902
        // Set the updated bs_const value
1903
        parsedManifest.set('bs_const', constString);
15✔
1904
    }
1905

1906
    /**
1907
     * Try to find and load the manifest into memory
1908
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1909
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1910
     */
1911
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,474✔
1912
        //if we already have a manifest instance, and should not replace...then don't replace
1913
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,480!
UNCOV
1914
            return;
×
1915
        }
1916
        let manifestPath = manifestFileObj
1,480✔
1917
            ? manifestFileObj.src
1,480✔
1918
            : path.join(this.options.rootDir, 'manifest');
1919

1920
        try {
1,480✔
1921
            // we only load this manifest once, so do it sync to improve speed downstream
1922
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,480✔
1923
            const parsedManifest = parseManifest(contents);
15✔
1924
            this.buildBsConstsIntoParsedManifest(parsedManifest);
15✔
1925
            this._manifest = parsedManifest;
15✔
1926
        } catch (e) {
1927
            this._manifest = new Map();
1,465✔
1928
        }
1929
    }
1930

1931
    /**
1932
     * Get a map of the manifest information
1933
     */
1934
    public getManifest() {
1935
        if (!this._manifest) {
2,308✔
1936
            this.loadManifest();
1,473✔
1937
        }
1938
        return this._manifest;
2,308✔
1939
    }
1940

1941
    public dispose() {
1942
        this.plugins.emit('beforeProgramDispose', { program: this });
1,647✔
1943

1944
        for (let filePath in this.files) {
1,647✔
1945
            this.files[filePath]?.dispose?.();
2,067!
1946
        }
1947
        for (let name in this.scopes) {
1,647✔
1948
            this.scopes[name]?.dispose?.();
3,485!
1949
        }
1950
        this.globalScope?.dispose?.();
1,647!
1951
        this.dependencyGraph?.dispose?.();
1,647!
1952
    }
1953
}
1954

1955
export interface FileTranspileResult {
1956
    srcPath: string;
1957
    destPath: string;
1958
    pkgPath: string;
1959
    code: string;
1960
    map: string;
1961
    typedef: string;
1962
}
1963

1964

1965
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
1966
    constructor(
1967
        public program: Program,
2,386✔
1968
        public srcPath: string,
2,386✔
1969
        public destPath: string,
2,386✔
1970
        public data: LazyFileData,
2,386✔
1971
        public fileFactory: FileFactory
2,386✔
1972
    ) {
1973
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,386!
1974
    }
1975

1976
    public srcExtension: string;
1977

1978
    public files: TFile[] = [];
2,386✔
1979
}
1980

1981
export interface ProgramBuildOptions {
1982
    /**
1983
     * The directory where the final built files should be placed. This directory will be cleared before running
1984
     */
1985
    stagingDir?: string;
1986
    /**
1987
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
1988
     * Typically you will want to leave this blank
1989
     */
1990
    files?: BscFile[];
1991
}
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

© 2025 Coveralls, Inc