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

rokucommunity / brighterscript / #13592

13 Jan 2025 02:40PM UTC coverage: 86.911%. Remained the same
#13592

push

web-flow
Merge 38702985d into 9d6ef67ba

12071 of 14663 branches covered (82.32%)

Branch coverage included in aggregate %.

90 of 94 new or added lines in 11 files covered. (95.74%)

93 existing lines in 8 files now uncovered.

13048 of 14239 relevant lines covered (91.64%)

31884.8 hits per line

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

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

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

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

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

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

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

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

119
        this.populateGlobalSymbolTable();
1,841✔
120

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

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

129
        // Get declarations for all annotations from all plugins
130
        this.populateAnnotationSymbolTable();
1,841✔
131
    }
132

133

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

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

169
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
1,841✔
170

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

182
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
809,387✔
183

184
        for (const callable of globalCallables) {
1,841✔
185
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
143,598✔
186
        }
187

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

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

203
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
1,841✔
204
            this.recursivelyAddNodeToSymbolTable(nodeData);
176,736✔
205
        }
206

207
        for (const eventData of Object.values(events) as BRSEventData[]) {
1,841✔
208
            const nodeType = new InterfaceType(eventData.name);
33,138✔
209
            nodeType.addBuiltInInterfaces();
33,138✔
210
            this.globalScope.symbolTable.addSymbol(eventData.name, { ...builtInSymbolData, description: eventData.description }, nodeType, SymbolTypeFlag.typetime);
33,138✔
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,841✔
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,841✔
230

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

236
    public pluginAnnotationTable = new SymbolTable('Plugin Annotations', () => this.globalScope?.symbolTable);
1,841!
237

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

254
    public addAnnotationSymbol(name: string, annoType: TypedFunctionType, extraData: ExtraSymbolData = {}) {
22✔
255
        if (name && annoType) {
24!
256
            annoType.setName(name);
24✔
257
            this.pluginAnnotationTable.addSymbol(name, extraData, annoType, SymbolTypeFlag.annotation);
24✔
258
        }
259
    }
260

261
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
1,841✔
262

263
    public addFileSymbolInfo(file: BrsFile) {
264
        this.fileSymbolInformation.set(file.pkgPath, {
1,690✔
265
            provides: file.providedSymbols,
266
            requires: file.requiredSymbols
267
        });
268
    }
269

270
    public getFileSymbolInfo(file: BrsFile) {
271
        return this.fileSymbolInformation.get(file.pkgPath);
1,693✔
272
    }
273

274
    /**
275
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
276
     */
277
    public get bslibPkgPath() {
278
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
279
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
2,466✔
280
            return bslibAliasedRokuModulesPkgPath;
11✔
281

282
            //if there's a non-aliased version of bslib from roku_modules, use that
283
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
2,455✔
284
            return bslibNonAliasedRokuModulesPkgPath;
24✔
285

286
            //default to the embedded version
287
        } else {
288
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
2,431✔
289
        }
290
    }
291

292
    public get bslibPrefix() {
293
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
1,797✔
294
            return 'rokucommunity_bslib';
18✔
295
        } else {
296
            return 'bslib';
1,779✔
297
        }
298
    }
299

300

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

316
    private scopes = {} as Record<string, Scope>;
1,841✔
317

318
    protected addScope(scope: Scope) {
319
        this.scopes[scope.name] = scope;
1,976✔
320
        delete this.sortedScopeNames;
1,976✔
321
    }
322

323
    protected removeScope(scope: Scope) {
324
        if (this.scopes[scope.name]) {
11!
325
            delete this.scopes[scope.name];
11✔
326
            delete this.sortedScopeNames;
11✔
327
        }
328
    }
329

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

338
    /**
339
     * Get the component with the specified name
340
     */
341
    public getComponent(componentName: string) {
342
        if (componentName) {
1,807✔
343
            //return the first compoment in the list with this name
344
            //(components are ordered in this list by destPath to ensure consistency)
345
            return this.components[componentName.toLowerCase()]?.[0];
1,793✔
346
        } else {
347
            return undefined;
14✔
348
        }
349
    }
350

351
    /**
352
     * Get the sorted names of custom components
353
     */
354
    public getSortedComponentNames() {
355
        const componentNames = Object.keys(this.components);
1,377✔
356
        componentNames.sort((a, b) => {
1,377✔
357
            if (a < b) {
696✔
358
                return -1;
272✔
359
            } else if (b < a) {
424!
360
                return 1;
424✔
361
            }
UNCOV
362
            return 0;
×
363
        });
364
        return componentNames;
1,377✔
365
    }
366

367
    /**
368
     * Keeps a set of all the components that need to have their types updated during the current validation cycle
369
     */
370
    private componentSymbolsToUpdate = new Set<{ componentKey: string; componentName: string }>();
1,841✔
371

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

398
    /**
399
     * Remove the specified component from the components map
400
     */
401
    private unregisterComponent(xmlFile: XmlFile) {
402
        const key = this.getComponentKey(xmlFile);
11✔
403
        const arr = this.components[key] || [];
11!
404
        for (let i = 0; i < arr.length; i++) {
11✔
405
            if (arr[i].file === xmlFile) {
11!
406
                arr.splice(i, 1);
11✔
407
                break;
11✔
408
            }
409
        }
410

411
        this.syncComponentDependencyGraph(arr);
11✔
412
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
11✔
413
    }
414

415
    /**
416
     * Adds a component described in an XML to the set of components that needs to be updated this validation cycle.
417
     * @param xmlFile XML file with <component> tag
418
     */
419
    private addDeferredComponentTypeSymbolCreation(xmlFile: XmlFile) {
420
        this.componentSymbolsToUpdate.add({ componentKey: this.getComponentKey(xmlFile), componentName: xmlFile.componentName?.text });
389✔
421

422
    }
423

424
    private getComponentKey(xmlFile: XmlFile) {
425
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
778✔
426
    }
427

428
    /**
429
     * Updates the global symbol table with the first component in this.components to have the same name as the component in the file
430
     * @param componentKey key getting a component from `this.components`
431
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
432
     */
433
    private updateComponentSymbolInGlobalScope(componentKey: string, componentName: string) {
434
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
308✔
435
        if (!symbolName) {
308✔
436
            return;
7✔
437
        }
438
        const components = this.components[componentKey] || [];
301!
439
        // Remove any existing symbols that match
440
        this.globalScope.symbolTable.removeSymbol(symbolName);
301✔
441
        // There is a component that can be added - use it.
442
        if (components.length > 0) {
301✔
443
            const componentScope = components[0].scope;
300✔
444
            // TODO: May need to link symbol tables to get correct types for callfuncs
445
            // componentScope.linkSymbolTable();
446
            const componentType = componentScope.getComponentType();
300✔
447
            if (componentType) {
300!
448
                this.globalScope.symbolTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
300✔
449
            }
450
            // TODO: Remember to unlink! componentScope.unlinkSymbolTable();
451
        }
452
    }
453

454
    /**
455
     * re-attach the dependency graph with a new key for any component who changed
456
     * their position in their own named array (only matters when there are multiple
457
     * components with the same name)
458
     */
459
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
460
        //reattach every dependency graph
461
        for (let i = 0; i < components.length; i++) {
389✔
462
            const { file, scope } = components[i];
384✔
463

464
            //attach (or re-attach) the dependencyGraph for every component whose position changed
465
            if (file.dependencyGraphIndex !== i) {
384✔
466
                file.dependencyGraphIndex = i;
380✔
467
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
380✔
468
                file.attachDependencyGraph(this.dependencyGraph);
380✔
469
                scope.attachDependencyGraph(this.dependencyGraph);
380✔
470
            }
471
        }
472
    }
473

474
    /**
475
     * Get a list of all files that are included in the project but are not referenced
476
     * by any scope in the program.
477
     */
478
    public getUnreferencedFiles() {
UNCOV
479
        let result = [] as BscFile[];
×
UNCOV
480
        for (let filePath in this.files) {
×
UNCOV
481
            let file = this.files[filePath];
×
482
            //is this file part of a scope
UNCOV
483
            if (!this.getFirstScopeForFile(file)) {
×
484
                //no scopes reference this file. add it to the list
UNCOV
485
                result.push(file);
×
486
            }
487
        }
UNCOV
488
        return result;
×
489
    }
490

491
    /**
492
     * Get the list of errors for the entire program.
493
     */
494
    public getDiagnostics() {
495
        return this.diagnostics.getDiagnostics();
1,175✔
496
    }
497

498
    /**
499
     * Determine if the specified file is loaded in this program right now.
500
     * @param filePath the absolute or relative path to the file
501
     * @param normalizePath should the provided path be normalized before use
502
     */
503
    public hasFile(filePath: string, normalizePath = true) {
2,558✔
504
        return !!this.getFile(filePath, normalizePath);
2,558✔
505
    }
506

507
    /**
508
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
509
     * @param scopeName xml scope names are their `destPath`. Source scope is stored with the key `"source"`
510
     */
511
    public getScopeByName(scopeName: string): Scope | undefined {
512
        if (!scopeName) {
57!
513
            return undefined;
×
514
        }
515
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
516
        //so it's safe to run the standardizePkgPath method
517
        scopeName = s`${scopeName}`;
57✔
518
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
131✔
519
        return this.scopes[key!];
57✔
520
    }
521

522
    /**
523
     * Return all scopes
524
     */
525
    public getScopes() {
526
        return Object.values(this.scopes);
12✔
527
    }
528

529
    /**
530
     * Find the scope for the specified component
531
     */
532
    public getComponentScope(componentName: string) {
533
        return this.getComponent(componentName)?.scope;
427✔
534
    }
535

536
    /**
537
     * Update internal maps with this file reference
538
     */
539
    private assignFile<T extends BscFile = BscFile>(file: T) {
540
        const fileAddEvent: BeforeFileAddEvent = {
2,379✔
541
            file: file,
542
            program: this
543
        };
544

545
        this.plugins.emit('beforeFileAdd', fileAddEvent);
2,379✔
546

547
        this.files[file.srcPath.toLowerCase()] = file;
2,379✔
548
        this.destMap.set(file.destPath.toLowerCase(), file);
2,379✔
549

550
        this.plugins.emit('afterFileAdd', fileAddEvent);
2,379✔
551

552
        return file;
2,379✔
553
    }
554

555
    /**
556
     * Remove this file from internal maps
557
     */
558
    private unassignFile<T extends BscFile = BscFile>(file: T) {
559
        delete this.files[file.srcPath.toLowerCase()];
152✔
560
        this.destMap.delete(file.destPath.toLowerCase());
152✔
561
        return file;
152✔
562
    }
563

564
    /**
565
     * Load a file into the program. If that file already exists, it is replaced.
566
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
567
     * @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:/`)
568
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
569
     */
570
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileData?: FileData): T;
571
    /**
572
     * Load a file into the program. If that file already exists, it is replaced.
573
     * @param fileEntry an object that specifies src and dest for the file.
574
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
575
     */
576
    public setFile<T extends BscFile>(fileEntry: FileObj, fileData: FileData): T;
577
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileData: FileData): T {
578
        //normalize the file paths
579
        const { srcPath, destPath } = this.getPaths(fileParam, this.options.rootDir);
2,375✔
580

581
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
2,375✔
582
            //if the file is already loaded, remove it
583
            if (this.hasFile(srcPath)) {
2,375✔
584
                this.removeFile(srcPath, true, true);
136✔
585
            }
586

587
            const data = new LazyFileData(fileData);
2,375✔
588

589
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
2,375✔
590

591
            this.plugins.emit('beforeProvideFile', event);
2,375✔
592
            this.plugins.emit('provideFile', event);
2,375✔
593
            this.plugins.emit('afterProvideFile', event);
2,375✔
594

595
            //if no files were provided, create a AssetFile to represent it.
596
            if (event.files.length === 0) {
2,375✔
597
                event.files.push(
18✔
598
                    this.fileFactory.AssetFile({
599
                        srcPath: event.srcPath,
600
                        destPath: event.destPath,
601
                        pkgPath: event.destPath,
602
                        data: data
603
                    })
604
                );
605
            }
606

607
            //find the file instance for the srcPath that triggered this action.
608
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
2,375✔
609

610
            if (!primaryFile) {
2,375!
UNCOV
611
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
612
                    type: x.type,
613
                    srcPath: x.srcPath,
614
                    destPath: x.destPath
615
                })))}`);
616
            }
617

618
            //link the virtual files to the primary file
619
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
2,375!
620

621
            for (const file of event.files) {
2,375✔
622
                file.srcPath = s(file.srcPath);
2,379✔
623
                if (file.destPath) {
2,379!
624
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
2,379✔
625
                }
626
                if (file.pkgPath) {
2,379✔
627
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
2,375✔
628
                } else {
629
                    file.pkgPath = file.destPath;
4✔
630
                }
631
                file.excludeFromOutput = file.excludeFromOutput === true;
2,379✔
632

633
                //set the dependencyGraph key for every file to its destPath
634
                file.dependencyGraphKey = file.destPath.toLowerCase();
2,379✔
635

636
                this.assignFile(file);
2,379✔
637

638
                //register a callback anytime this file's dependencies change
639
                if (typeof file.onDependenciesChanged === 'function') {
2,379✔
640
                    file.disposables ??= [];
2,353!
641
                    file.disposables.push(
2,353✔
642
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
643
                    );
644
                }
645

646
                //register this file (and its dependencies) with the dependency graph
647
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
2,379✔
648

649
                //if this is a `source` file, add it to the source scope's dependency list
650
                if (this.isSourceBrsFile(file)) {
2,379✔
651
                    this.createSourceScope();
1,648✔
652
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
1,648✔
653
                }
654

655
                //if this is an xml file in the components folder, register it as a component
656
                if (this.isComponentsXmlFile(file)) {
2,379✔
657
                    //create a new scope for this xml file
658
                    let scope = new XmlScope(file, this);
378✔
659
                    this.addScope(scope);
378✔
660

661
                    //register this compoent now that we have parsed it and know its component name
662
                    this.registerComponent(file, scope);
378✔
663

664
                    //notify plugins that the scope is created and the component is registered
665
                    this.plugins.emit('afterScopeCreate', {
378✔
666
                        program: this,
667
                        scope: scope
668
                    });
669
                }
670
            }
671

672
            return primaryFile;
2,375✔
673
        });
674
        return file as T;
2,375✔
675
    }
676

677
    /**
678
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
679
     * @param fileParam an object representing file paths
680
     * @param rootDir must be a pre-normalized path
681
     */
682
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
683
        let srcPath: string | undefined;
684
        let destPath: string | undefined;
685

686
        assert.ok(fileParam, 'fileParam is required');
2,530✔
687

688
        //lift the path vars from the incoming param
689
        if (typeof fileParam === 'string') {
2,530✔
690
            fileParam = this.removePkgPrefix(fileParam);
2,168✔
691
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,168✔
692
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,168✔
693
        } else {
694
            let param: any = fileParam;
362✔
695

696
            if (param.src) {
362✔
697
                srcPath = s`${param.src}`;
361✔
698
            }
699
            if (param.srcPath) {
362!
UNCOV
700
                srcPath = s`${param.srcPath}`;
×
701
            }
702
            if (param.dest) {
362✔
703
                destPath = s`${this.removePkgPrefix(param.dest)}`;
361✔
704
            }
705
            if (param.pkgPath) {
362!
UNCOV
706
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
707
            }
708
        }
709

710
        //if there's no srcPath, use the destPath to build an absolute srcPath
711
        if (!srcPath) {
2,530✔
712
            srcPath = s`${rootDir}/${destPath}`;
1✔
713
        }
714
        //coerce srcPath to an absolute path
715
        if (!path.isAbsolute(srcPath)) {
2,530✔
716
            srcPath = util.standardizePath(srcPath);
1✔
717
        }
718

719
        //if destPath isn't set, compute it from the other paths
720
        if (!destPath) {
2,530✔
721
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
722
        }
723

724
        assert.ok(srcPath, 'fileEntry.src is required');
2,530✔
725
        assert.ok(destPath, 'fileEntry.dest is required');
2,530✔
726

727
        return {
2,530✔
728
            srcPath: srcPath,
729
            //remove leading slash
730
            destPath: destPath.replace(/^[\/\\]+/, '')
731
        };
732
    }
733

734
    /**
735
     * Remove any leading `pkg:/` found in the path
736
     */
737
    private removePkgPrefix(path: string) {
738
        return path.replace(/^pkg:\//i, '');
2,529✔
739
    }
740

741
    /**
742
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
743
     */
744
    private isSourceBrsFile(file: BscFile) {
745
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
2,531✔
746
    }
747

748
    /**
749
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
750
     */
751
    private isComponentsXmlFile(file: BscFile): file is XmlFile {
752
        return isXmlFile(file) && !!/^(pkg:\/)?components[\/\\]/.exec(file.destPath);
2,379✔
753
    }
754

755
    /**
756
     * Ensure source scope is created.
757
     * Note: automatically called internally, and no-op if it exists already.
758
     */
759
    public createSourceScope() {
760
        if (!this.scopes.source) {
2,419✔
761
            const sourceScope = new Scope('source', this, 'scope:source');
1,598✔
762
            sourceScope.attachDependencyGraph(this.dependencyGraph);
1,598✔
763
            this.addScope(sourceScope);
1,598✔
764
            this.plugins.emit('afterScopeCreate', {
1,598✔
765
                program: this,
766
                scope: sourceScope
767
            });
768
        }
769
    }
770

771
    /**
772
     * Remove a set of files from the program
773
     * @param srcPaths can be an array of srcPath or destPath strings
774
     * @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
775
     */
776
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
777
        for (let srcPath of srcPaths) {
1✔
778
            this.removeFile(srcPath, normalizePath);
1✔
779
        }
780
    }
781

782
    /**
783
     * Remove a file from the program
784
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
785
     * @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
786
     */
787
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
27✔
788
        this.logger.debug('Program.removeFile()', filePath);
150✔
789
        const paths = this.getPaths(filePath, this.options.rootDir);
150✔
790

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

794
        for (const file of files) {
150✔
795
            //if a file has already been removed, nothing more needs to be done here
796
            if (!file || !this.hasFile(file.srcPath)) {
153✔
797
                continue;
1✔
798
            }
799
            this.diagnostics.clearForFile(file.srcPath);
152✔
800

801
            const event: BeforeFileRemoveEvent = { file: file, program: this };
152✔
802
            this.plugins.emit('beforeFileRemove', event);
152✔
803

804
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
805
            let scope = this.scopes[file.destPath];
152✔
806
            if (scope) {
152✔
807
                const scopeDisposeEvent = {
11✔
808
                    program: this,
809
                    scope: scope
810
                };
811
                this.plugins.emit('beforeScopeDispose', scopeDisposeEvent);
11✔
812
                this.plugins.emit('onScopeDispose', scopeDisposeEvent);
11✔
813
                scope.dispose();
11✔
814
                //notify dependencies of this scope that it has been removed
815
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
11✔
816
                this.removeScope(this.scopes[file.destPath]);
11✔
817
                this.plugins.emit('afterScopeDispose', scopeDisposeEvent);
11✔
818
            }
819
            //remove the file from the program
820
            this.unassignFile(file);
152✔
821

822
            this.dependencyGraph.remove(file.dependencyGraphKey);
152✔
823

824
            //if this is a pkg:/source file, notify the `source` scope that it has changed
825
            if (this.isSourceBrsFile(file)) {
152✔
826
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
126✔
827
            }
828
            if (isBrsFile(file)) {
152✔
829
                if (!keepSymbolInformation) {
135✔
830
                    this.fileSymbolInformation.delete(file.pkgPath);
8✔
831
                }
832
                this.crossScopeValidation.clearResolutionsForFile(file);
135✔
833
            }
834

835
            //if this is a component, remove it from our components map
836
            if (isXmlFile(file)) {
152✔
837
                this.unregisterComponent(file);
11✔
838
            }
839
            //dispose any disposable things on the file
840
            for (const disposable of file?.disposables ?? []) {
152!
841
                disposable();
146✔
842
            }
843
            //dispose file
844
            file?.dispose?.();
152!
845

846
            this.plugins.emit('afterFileRemove', event);
152✔
847
        }
848
    }
849

850
    public crossScopeValidation = new CrossScopeValidator(this);
1,841✔
851

852
    private isFirstValidation = true;
1,841✔
853

854
    /**
855
     * Traverse the entire project, and validate all scopes
856
     */
857
    public validate() {
858
        this.logger.time(LogLevel.log, ['Validating project'], () => {
1,377✔
859
            this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
1,377✔
860
            const programValidateEvent = {
1,377✔
861
                program: this
862
            };
863
            this.plugins.emit('beforeProgramValidate', programValidateEvent);
1,377✔
864
            this.plugins.emit('onProgramValidate', programValidateEvent);
1,377✔
865

866
            const metrics = {
1,377✔
867
                filesChanged: 0,
868
                filesValidated: 0,
869
                fileValidationTime: '',
870
                crossScopeValidationTime: '',
871
                scopesValidated: 0,
872
                totalLinkTime: '',
873
                totalScopeValidationTime: '',
874
                componentValidationTime: ''
875
            };
876

877
            const validationStopwatch = new Stopwatch();
1,377✔
878
            //validate every file
879
            const brsFilesValidated: BrsFile[] = [];
1,377✔
880
            const afterValidateFiles: BscFile[] = [];
1,377✔
881

882
            metrics.fileValidationTime = validationStopwatch.getDurationTextFor(() => {
1,377✔
883
                //sort files by path so we get consistent results
884
                const files = Object.values(this.files).sort(firstBy(x => x.srcPath));
3,488✔
885
                for (const file of files) {
1,377✔
886
                    //for every unvalidated file, validate it
887
                    if (!file.isValidated) {
2,269✔
888
                        const validateFileEvent = {
1,937✔
889
                            program: this,
890
                            file: file
891
                        };
892
                        this.plugins.emit('beforeFileValidate', validateFileEvent);
1,937✔
893
                        //emit an event to allow plugins to contribute to the file validation process
894
                        this.plugins.emit('onFileValidate', validateFileEvent);
1,937✔
895
                        file.isValidated = true;
1,937✔
896
                        if (isBrsFile(file)) {
1,937✔
897
                            brsFilesValidated.push(file);
1,632✔
898
                        }
899
                        afterValidateFiles.push(file);
1,937✔
900
                    }
901
                }
902
                // AfterFileValidate is after all files have been validated
903
                for (const file of afterValidateFiles) {
1,377✔
904
                    const validateFileEvent = {
1,937✔
905
                        program: this,
906
                        file: file
907
                    };
908
                    this.plugins.emit('afterFileValidate', validateFileEvent);
1,937✔
909
                }
910
            }).durationText;
911

912
            metrics.filesChanged = afterValidateFiles.length;
1,377✔
913

914
            // Build component types for any component that changes
915
            this.logger.time(LogLevel.info, ['Build component types'], () => {
1,377✔
916
                for (let { componentKey, componentName } of this.componentSymbolsToUpdate) {
1,377✔
917
                    this.updateComponentSymbolInGlobalScope(componentKey, componentName);
308✔
918
                }
919
                this.componentSymbolsToUpdate.clear();
1,377✔
920
            });
921

922

923
            const changedSymbolsMapArr = brsFilesValidated?.map(f => {
1,377!
924
                if (isBrsFile(f)) {
1,632!
925
                    return f.providedSymbols.changes;
1,632✔
926
                }
UNCOV
927
                return null;
×
928
            }).filter(x => x);
1,632✔
929

930
            const changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
1,377✔
931
            for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
1,377✔
932
                const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
3,264✔
933
                changedSymbols.set(flag, new Set(...changedSymbolsSetArr));
2,754✔
934
            }
935

936
            const filesToBeValidatedInScopeContext = new Set<BscFile>(afterValidateFiles);
1,377✔
937

938
            metrics.crossScopeValidationTime = validationStopwatch.getDurationTextFor(() => {
1,377✔
939
                const scopesToCheck = this.getScopesForCrossScopeValidation();
1,377✔
940
                this.crossScopeValidation.buildComponentsMap();
1,377✔
941
                this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
1,377✔
942
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
1,377✔
943
                for (const file of filesToRevalidate) {
1,377✔
944
                    filesToBeValidatedInScopeContext.add(file);
178✔
945
                }
946
            }).durationText;
947

948
            metrics.filesValidated = filesToBeValidatedInScopeContext.size;
1,377✔
949

950
            let linkTime = 0;
1,377✔
951
            let validationTime = 0;
1,377✔
952
            let scopesValidated = 0;
1,377✔
953
            let changedFiles = new Set<BscFile>(afterValidateFiles);
1,377✔
954
            this.logger.time(LogLevel.info, ['Validate all scopes'], () => {
1,377✔
955
                //sort the scope names so we get consistent results
956
                const scopeNames = this.getSortedScopeNames();
1,377✔
957
                for (const file of filesToBeValidatedInScopeContext) {
1,377✔
958
                    if (isBrsFile(file)) {
2,058✔
959
                        file.validationSegmenter.unValidateAllSegments();
1,753✔
960
                    }
961
                }
962
                for (let scopeName of scopeNames) {
1,377✔
963
                    let scope = this.scopes[scopeName];
3,104✔
964
                    const scopeValidated = scope.validate({
3,104✔
965
                        filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
966
                        changedSymbols: changedSymbols,
967
                        changedFiles: changedFiles,
968
                        initialValidation: this.isFirstValidation
969
                    });
970
                    if (scopeValidated) {
3,104✔
971
                        scopesValidated++;
1,677✔
972
                    }
973
                    linkTime += scope.validationMetrics.linkTime;
3,104✔
974
                    validationTime += scope.validationMetrics.validationTime;
3,104✔
975
                }
976
            });
977
            metrics.scopesValidated = scopesValidated;
1,377✔
978
            validationStopwatch.totalMilliseconds = linkTime;
1,377✔
979
            metrics.totalLinkTime = validationStopwatch.getDurationText();
1,377✔
980

981
            validationStopwatch.totalMilliseconds = validationTime;
1,377✔
982
            metrics.totalScopeValidationTime = validationStopwatch.getDurationText();
1,377✔
983

984
            metrics.componentValidationTime = validationStopwatch.getDurationTextFor(() => {
1,377✔
985
                this.detectDuplicateComponentNames();
1,377✔
986
            }).durationText;
987

988
            this.logValidationMetrics(metrics);
1,377✔
989

990
            this.isFirstValidation = false;
1,377✔
991

992
            this.plugins.emit('afterProgramValidate', programValidateEvent);
1,377✔
993
        });
994
    }
995

996
    // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
997
    private logValidationMetrics(metrics: { [key: string]: number | string }) {
998
        let logs = [] as string[];
1,377✔
999
        for (const key in metrics) {
1,377✔
1000
            logs.push(`${key}=${chalk.yellow(metrics[key].toString())}`);
11,016✔
1001
        }
1002
        this.logger.info(`Validation Metrics: ${logs.join(', ')}`);
1,377✔
1003
    }
1004

1005
    private getScopesForCrossScopeValidation() {
1006
        const scopesForCrossScopeValidation = [];
1,377✔
1007
        for (let scopeName of this.getSortedScopeNames()) {
1,377✔
1008
            let scope = this.scopes[scopeName];
3,104✔
1009
            if (this.globalScope !== scope && !scope.isValidated) {
3,104✔
1010
                scopesForCrossScopeValidation.push(scope);
1,698✔
1011
            }
1012
        }
1013
        return scopesForCrossScopeValidation;
1,377✔
1014
    }
1015

1016
    /**
1017
     * Flag all duplicate component names
1018
     */
1019
    private detectDuplicateComponentNames() {
1020
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
1,377✔
1021
            const file = this.files[filePath];
2,269✔
1022
            //if this is an XmlFile, and it has a valid `componentName` property
1023
            if (isXmlFile(file) && file.componentName?.text) {
2,269✔
1024
                let lowerName = file.componentName.text.toLowerCase();
437✔
1025
                if (!map[lowerName]) {
437✔
1026
                    map[lowerName] = [];
434✔
1027
                }
1028
                map[lowerName].push(file);
437✔
1029
            }
1030
            return map;
2,269✔
1031
        }, {});
1032

1033
        for (let name in componentsByName) {
1,377✔
1034
            const xmlFiles = componentsByName[name];
434✔
1035
            //add diagnostics for every duplicate component with this name
1036
            if (xmlFiles.length > 1) {
434✔
1037
                for (let xmlFile of xmlFiles) {
3✔
1038
                    const { componentName } = xmlFile;
6✔
1039
                    this.diagnostics.register({
6✔
1040
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1041
                        location: xmlFile.componentName.location,
1042
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1043
                            return {
6✔
1044
                                location: x.componentName.location,
1045
                                message: 'Also defined here'
1046
                            };
1047
                        })
1048
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1049
                }
1050
            }
1051
        }
1052
    }
1053

1054
    /**
1055
     * Get the files for a list of filePaths
1056
     * @param filePaths can be an array of srcPath or a destPath strings
1057
     * @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
1058
     */
1059
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
29✔
1060
        return filePaths
29✔
1061
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1062
            .filter(file => file !== undefined) as T[];
39✔
1063
    }
1064

1065
    /**
1066
     * Get the file at the given path
1067
     * @param filePath can be a srcPath or a destPath
1068
     * @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
1069
     */
1070
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
15,162✔
1071
        if (typeof filePath !== 'string') {
19,158✔
1072
            return undefined;
3,407✔
1073
            //is the path absolute (or the `virtual:` prefix)
1074
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
15,751✔
1075
            return this.files[
4,618✔
1076
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
4,618!
1077
            ] as T;
1078
        } else if (util.isUriLike(filePath)) {
11,133✔
1079
            const path = URI.parse(filePath).fsPath;
1,344✔
1080
            return this.files[
1,344✔
1081
                (normalizePath ? util.standardizePath(path) : path).toLowerCase()
1,344!
1082
            ] as T;
1083
        } else {
1084
            return this.destMap.get(
9,789✔
1085
                (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase()
9,789✔
1086
            ) as T;
1087
        }
1088
    }
1089

1090
    private sortedScopeNames: string[] = undefined;
1,841✔
1091

1092
    /**
1093
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1094
     */
1095
    private getSortedScopeNames() {
1096
        if (!this.sortedScopeNames) {
7,510✔
1097
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,332✔
1098
                if (a === 'global') {
1,862!
UNCOV
1099
                    return -1;
×
1100
                } else if (b === 'global') {
1,862✔
1101
                    return 1;
1,312✔
1102
                }
1103
                if (a === 'source') {
550✔
1104
                    return -1;
26✔
1105
                } else if (b === 'source') {
524✔
1106
                    return 1;
108✔
1107
                }
1108
                if (a < b) {
416✔
1109
                    return -1;
166✔
1110
                } else if (b < a) {
250!
1111
                    return 1;
250✔
1112
                }
UNCOV
1113
                return 0;
×
1114
            });
1115
        }
1116
        return this.sortedScopeNames;
7,510✔
1117
    }
1118

1119
    /**
1120
     * Get a list of all scopes the file is loaded into
1121
     * @param file the file
1122
     */
1123
    public getScopesForFile(file: BscFile | string) {
1124
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
717✔
1125

1126
        let result = [] as Scope[];
717✔
1127
        if (resolvedFile) {
717✔
1128
            const scopeKeys = this.getSortedScopeNames();
716✔
1129
            for (let key of scopeKeys) {
716✔
1130
                let scope = this.scopes[key];
1,495✔
1131

1132
                if (scope.hasFile(resolvedFile)) {
1,495✔
1133
                    result.push(scope);
730✔
1134
                }
1135
            }
1136
        }
1137
        return result;
717✔
1138
    }
1139

1140
    /**
1141
     * Get the first found scope for a file.
1142
     */
1143
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1144
        const scopeKeys = this.getSortedScopeNames();
4,040✔
1145
        for (let key of scopeKeys) {
4,040✔
1146
            let scope = this.scopes[key];
18,423✔
1147

1148
            if (scope.hasFile(file)) {
18,423✔
1149
                return scope;
2,929✔
1150
            }
1151
        }
1152
    }
1153

1154
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1155
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1156
        const filesSearched = new Set<BrsFile>();
39✔
1157
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1158
        let lowerName = name?.toLowerCase();
39!
1159

1160
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1161
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1162
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1163
                if (!results.has(statement)) {
36!
1164
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1165
                }
1166
            }
1167
        }
1168

1169
        //look through all files in scope for matches
1170
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1171
            for (const file of scope.getAllFiles()) {
39✔
1172
                //skip non-brs files, or files we've already processed
1173
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1174
                    continue;
3✔
1175
                }
1176
                filesSearched.add(file);
42✔
1177

1178
                file.ast.walk(createVisitor({
42✔
1179
                    FunctionStatement: (statement: FunctionStatement) => {
1180
                        addToResults(statement, file);
95✔
1181
                    },
1182
                    MethodStatement: (statement: MethodStatement) => {
1183
                        addToResults(statement, file);
3✔
1184
                    }
1185
                }), {
1186
                    walkMode: WalkMode.visitStatements
1187
                });
1188
            }
1189
        }
1190
        return [...results.values()];
39✔
1191
    }
1192

1193
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1194
        let results = new Map<Statement, FileLink<FunctionStatement>>();
10✔
1195
        const filesSearched = new Set<BrsFile>();
10✔
1196

1197
        //get all function names for the xml file and parents
1198
        let funcNames = new Set<string>();
10✔
1199
        let currentScope = scope;
10✔
1200
        while (isXmlScope(currentScope)) {
10✔
1201
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
16✔
1202
                if (!filterName || name === filterName) {
16!
1203
                    funcNames.add(name);
16✔
1204
                }
1205
            }
1206
            currentScope = currentScope.getParentScope() as XmlScope;
12✔
1207
        }
1208

1209
        //look through all files in scope for matches
1210
        for (const file of scope.getOwnFiles()) {
10✔
1211
            //skip non-brs files, or files we've already processed
1212
            if (!isBrsFile(file) || filesSearched.has(file)) {
20✔
1213
                continue;
10✔
1214
            }
1215
            filesSearched.add(file);
10✔
1216

1217
            file.ast.walk(createVisitor({
10✔
1218
                FunctionStatement: (statement: FunctionStatement) => {
1219
                    if (funcNames.has(statement.tokens.name.text)) {
15!
1220
                        if (!results.has(statement)) {
15!
1221
                            results.set(statement, { item: statement, file: file });
15✔
1222
                        }
1223
                    }
1224
                }
1225
            }), {
1226
                walkMode: WalkMode.visitStatements
1227
            });
1228
        }
1229
        return [...results.values()];
10✔
1230
    }
1231

1232
    /**
1233
     * Find all available completion items at the given position
1234
     * @param filePath can be a srcPath or a destPath
1235
     * @param position the position (line & column) where completions should be found
1236
     */
1237
    public getCompletions(filePath: string, position: Position) {
1238
        let file = this.getFile(filePath);
116✔
1239
        if (!file) {
116!
UNCOV
1240
            return [];
×
1241
        }
1242

1243
        //find the scopes for this file
1244
        let scopes = this.getScopesForFile(file);
116✔
1245

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

1249
        const event: ProvideCompletionsEvent = {
116✔
1250
            program: this,
1251
            file: file,
1252
            scopes: scopes,
1253
            position: position,
1254
            completions: []
1255
        };
1256

1257
        this.plugins.emit('beforeProvideCompletions', event);
116✔
1258

1259
        this.plugins.emit('provideCompletions', event);
116✔
1260

1261
        this.plugins.emit('afterProvideCompletions', event);
116✔
1262

1263
        return event.completions;
116✔
1264
    }
1265

1266
    /**
1267
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1268
     */
1269
    public getWorkspaceSymbols() {
1270
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1271
            program: this,
1272
            workspaceSymbols: []
1273
        };
1274
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1275
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1276
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1277
        return event.workspaceSymbols;
22✔
1278
    }
1279

1280
    /**
1281
     * Given a position in a file, if the position is sitting on some type of identifier,
1282
     * go to the definition of that identifier (where this thing was first defined)
1283
     */
1284
    public getDefinition(srcPath: string, position: Position): Location[] {
1285
        let file = this.getFile(srcPath);
18✔
1286
        if (!file) {
18!
UNCOV
1287
            return [];
×
1288
        }
1289

1290
        const event: ProvideDefinitionEvent = {
18✔
1291
            program: this,
1292
            file: file,
1293
            position: position,
1294
            definitions: []
1295
        };
1296

1297
        this.plugins.emit('beforeProvideDefinition', event);
18✔
1298
        this.plugins.emit('provideDefinition', event);
18✔
1299
        this.plugins.emit('afterProvideDefinition', event);
18✔
1300
        return event.definitions;
18✔
1301
    }
1302

1303
    /**
1304
     * Get hover information for a file and position
1305
     */
1306
    public getHover(srcPath: string, position: Position): Hover[] {
1307
        let file = this.getFile(srcPath);
68✔
1308
        let result: Hover[];
1309
        if (file) {
68!
1310
            const event = {
68✔
1311
                program: this,
1312
                file: file,
1313
                position: position,
1314
                scopes: this.getScopesForFile(file),
1315
                hovers: []
1316
            } as ProvideHoverEvent;
1317
            this.plugins.emit('beforeProvideHover', event);
68✔
1318
            this.plugins.emit('provideHover', event);
68✔
1319
            this.plugins.emit('afterProvideHover', event);
68✔
1320
            result = event.hovers;
68✔
1321
        }
1322

1323
        return result ?? [];
68!
1324
    }
1325

1326
    /**
1327
     * Get full list of document symbols for a file
1328
     * @param srcPath path to the file
1329
     */
1330
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1331
        let file = this.getFile(srcPath);
18✔
1332
        if (file) {
18!
1333
            const event: ProvideDocumentSymbolsEvent = {
18✔
1334
                program: this,
1335
                file: file,
1336
                documentSymbols: []
1337
            };
1338
            this.plugins.emit('beforeProvideDocumentSymbols', event);
18✔
1339
            this.plugins.emit('provideDocumentSymbols', event);
18✔
1340
            this.plugins.emit('afterProvideDocumentSymbols', event);
18✔
1341
            return event.documentSymbols;
18✔
1342
        } else {
UNCOV
1343
            return undefined;
×
1344
        }
1345
    }
1346

1347
    /**
1348
     * Compute code actions for the given file and range
1349
     */
1350
    public getCodeActions(srcPath: string, range: Range) {
1351
        const codeActions = [] as CodeAction[];
13✔
1352
        const file = this.getFile(srcPath);
13✔
1353
        if (file) {
13✔
1354
            const fileUri = util.pathToUri(file?.srcPath);
12!
1355
            const diagnostics = this
12✔
1356
                //get all current diagnostics (filtered by diagnostic filters)
1357
                .getDiagnostics()
1358
                //only keep diagnostics related to this file
1359
                .filter(x => x.location?.uri === fileUri)
22✔
1360
                //only keep diagnostics that touch this range
1361
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
12✔
1362

1363
            const scopes = this.getScopesForFile(file);
12✔
1364

1365
            this.plugins.emit('onGetCodeActions', {
12✔
1366
                program: this,
1367
                file: file,
1368
                range: range,
1369
                diagnostics: diagnostics,
1370
                scopes: scopes,
1371
                codeActions: codeActions
1372
            });
1373
        }
1374
        return codeActions;
13✔
1375
    }
1376

1377
    /**
1378
     * Get semantic tokens for the specified file
1379
     */
1380
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1381
        const file = this.getFile(srcPath);
24✔
1382
        if (file) {
24!
1383
            const result = [] as SemanticToken[];
24✔
1384
            this.plugins.emit('onGetSemanticTokens', {
24✔
1385
                program: this,
1386
                file: file,
1387
                scopes: this.getScopesForFile(file),
1388
                semanticTokens: result
1389
            });
1390
            return result;
24✔
1391
        }
1392
    }
1393

1394
    public getSignatureHelp(filepath: string, position: Position): SignatureInfoObj[] {
1395
        let file: BrsFile = this.getFile(filepath);
185✔
1396
        if (!file || !isBrsFile(file)) {
185✔
1397
            return [];
3✔
1398
        }
1399
        let callExpressionInfo = new CallExpressionInfo(file, position);
182✔
1400
        let signatureHelpUtil = new SignatureHelpUtil();
182✔
1401
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
182✔
1402
    }
1403

1404
    public getReferences(srcPath: string, position: Position): Location[] {
1405
        //find the file
1406
        let file = this.getFile(srcPath);
4✔
1407

1408
        const event: ProvideReferencesEvent = {
4✔
1409
            program: this,
1410
            file: file,
1411
            position: position,
1412
            references: []
1413
        };
1414

1415
        this.plugins.emit('beforeProvideReferences', event);
4✔
1416
        this.plugins.emit('provideReferences', event);
4✔
1417
        this.plugins.emit('afterProvideReferences', event);
4✔
1418

1419
        return event.references;
4✔
1420
    }
1421

1422
    /**
1423
     * Transpile a single file and get the result as a string.
1424
     * This does not write anything to the file system.
1425
     *
1426
     * This should only be called by `LanguageServer`.
1427
     * Internal usage should call `_getTranspiledFileContents` instead.
1428
     * @param filePath can be a srcPath or a destPath
1429
     */
1430
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
1431
        const file = this.getFile(filePath);
318✔
1432

1433
        return this.getTranspiledFileContentsPipeline.run(async () => {
318✔
1434

1435
            const result = {
318✔
1436
                destPath: file.destPath,
1437
                pkgPath: file.pkgPath,
1438
                srcPath: file.srcPath
1439
            } as FileTranspileResult;
1440

1441
            const expectedPkgPath = file.pkgPath.toLowerCase();
318✔
1442
            const expectedMapPath = `${expectedPkgPath}.map`;
318✔
1443
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
318✔
1444

1445
            //add a temporary plugin to tap into the file writing process
1446
            const plugin = this.plugins.addFirst({
318✔
1447
                name: 'getTranspiledFileContents',
1448
                beforeWriteFile: (event) => {
1449
                    const pkgPath = event.file.pkgPath.toLowerCase();
992✔
1450
                    switch (pkgPath) {
992✔
1451
                        //this is the actual transpiled file
1452
                        case expectedPkgPath:
992✔
1453
                            result.code = event.file.data.toString();
318✔
1454
                            break;
318✔
1455
                        //this is the sourcemap
1456
                        case expectedMapPath:
1457
                            result.map = event.file.data.toString();
170✔
1458
                            break;
170✔
1459
                        //this is the typedef
1460
                        case expectedTypedefPkgPath:
1461
                            result.typedef = event.file.data.toString();
8✔
1462
                            break;
8✔
1463
                        default:
1464
                        //no idea what this file is. just ignore it
1465
                    }
1466
                    //mark every file as processed so it they don't get written to the output directory
1467
                    event.processedFiles.add(event.file);
992✔
1468
                }
1469
            });
1470

1471
            try {
318✔
1472
                //now that the plugin has been registered, run the build with just this file
1473
                await this.build({
318✔
1474
                    files: [file]
1475
                });
1476
            } finally {
1477
                this.plugins.remove(plugin);
318✔
1478
            }
1479
            return result;
318✔
1480
        });
1481
    }
1482
    private getTranspiledFileContentsPipeline = new ActionPipeline();
1,841✔
1483

1484
    /**
1485
     * Get the absolute output path for a file
1486
     */
1487
    private getOutputPath(file: { pkgPath?: string }, stagingDir = this.getStagingDir()) {
×
1488
        return s`${stagingDir}/${file.pkgPath}`;
1,831✔
1489
    }
1490

1491
    private getStagingDir(stagingDir?: string) {
1492
        let result = stagingDir ?? this.options.stagingDir ?? this.options.stagingDir;
717✔
1493
        if (!result) {
717✔
1494
            result = rokuDeploy.getOptions(this.options as any).stagingDir;
531✔
1495
        }
1496
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
717!
1497
        return result;
717✔
1498
    }
1499

1500
    /**
1501
     * Prepare the program for building
1502
     * @param files the list of files that should be prepared
1503
     */
1504
    private async prepare(files: BscFile[]) {
1505
        const programEvent: PrepareProgramEvent = {
359✔
1506
            program: this,
1507
            editor: this.editor,
1508
            files: files
1509
        };
1510

1511
        //assign an editor to every file
1512
        for (const file of programEvent.files) {
359✔
1513
            //if the file doesn't have an editor yet, assign one now
1514
            if (!file.editor) {
728✔
1515
                file.editor = new Editor();
681✔
1516
            }
1517
        }
1518

1519
        //sort the entries to make transpiling more deterministic
1520
        programEvent.files.sort((a, b) => {
359✔
1521
            if (a.pkgPath < b.pkgPath) {
384✔
1522
                return -1;
324✔
1523
            } else if (a.pkgPath > b.pkgPath) {
60!
1524
                return 1;
60✔
1525
            } else {
UNCOV
1526
                return 1;
×
1527
            }
1528
        });
1529

1530
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
359✔
1531
        await this.plugins.emitAsync('prepareProgram', programEvent);
359✔
1532

1533
        const stagingDir = this.getStagingDir();
359✔
1534

1535
        const entries: TranspileObj[] = [];
359✔
1536

1537
        for (const file of files) {
359✔
1538
            const scope = this.getFirstScopeForFile(file);
728✔
1539
            //link the symbol table for all the files in this scope
1540
            scope?.linkSymbolTable();
728✔
1541

1542
            //if the file doesn't have an editor yet, assign one now
1543
            if (!file.editor) {
728!
UNCOV
1544
                file.editor = new Editor();
×
1545
            }
1546
            const event = {
728✔
1547
                program: this,
1548
                file: file,
1549
                editor: file.editor,
1550
                scope: scope,
1551
                outputPath: this.getOutputPath(file, stagingDir)
1552
            } as PrepareFileEvent & { outputPath: string };
1553

1554
            await this.plugins.emitAsync('beforePrepareFile', event);
728✔
1555
            await this.plugins.emitAsync('prepareFile', event);
728✔
1556
            await this.plugins.emitAsync('afterPrepareFile', event);
728✔
1557

1558
            //TODO remove this in v1
1559
            entries.push(event);
728✔
1560

1561
            //unlink the symbolTable so the next loop iteration can link theirs
1562
            scope?.unlinkSymbolTable();
728✔
1563
        }
1564

1565
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
359✔
1566
        return files;
359✔
1567
    }
1568

1569
    /**
1570
     * Generate the contents of every file
1571
     */
1572
    private async serialize(files: BscFile[]) {
1573

1574
        const allFiles = new Map<BscFile, SerializedFile[]>();
358✔
1575

1576
        //exclude prunable files if that option is enabled
1577
        if (this.options.pruneEmptyCodeFiles === true) {
358✔
1578
            files = files.filter(x => x.canBePruned !== true);
9✔
1579
        }
1580

1581
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
358✔
1582
            program: this,
1583
            files: files,
1584
            result: allFiles
1585
        });
1586
        await this.plugins.emitAsync('onSerializeProgram', serializeProgramEvent);
358✔
1587

1588
        // serialize each file
1589
        for (const file of files) {
358✔
1590
            let scope = this.getFirstScopeForFile(file);
725✔
1591

1592
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
1593
            if (!scope) {
725✔
1594
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
369✔
1595
                scope.getAllFiles = () => [file];
3,308✔
1596
                scope.getOwnFiles = scope.getAllFiles;
369✔
1597
            }
1598

1599
            //link the symbol table for all the files in this scope
1600
            scope?.linkSymbolTable();
725!
1601
            const event: SerializeFileEvent = {
725✔
1602
                program: this,
1603
                file: file,
1604
                scope: scope,
1605
                result: allFiles
1606
            };
1607
            await this.plugins.emitAsync('beforeSerializeFile', event);
725✔
1608
            await this.plugins.emitAsync('serializeFile', event);
725✔
1609
            await this.plugins.emitAsync('afterSerializeFile', event);
725✔
1610
            //unlink the symbolTable so the next loop iteration can link theirs
1611
            scope?.unlinkSymbolTable();
725!
1612
        }
1613

1614
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
358✔
1615

1616
        return allFiles;
358✔
1617
    }
1618

1619
    /**
1620
     * Write the entire project to disk
1621
     */
1622
    private async write(stagingDir: string, files: Map<BscFile, SerializedFile[]>) {
1623
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
358✔
1624
            program: this,
1625
            files: files,
1626
            stagingDir: stagingDir
1627
        });
1628
        //empty the staging directory
1629
        await fsExtra.emptyDir(stagingDir);
358✔
1630

1631
        const serializedFiles = [...files]
358✔
1632
            .map(([, serializedFiles]) => serializedFiles)
725✔
1633
            .flat();
1634

1635
        //write all the files to disk (asynchronously)
1636
        await Promise.all(
358✔
1637
            serializedFiles.map(async (file) => {
1638
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,103✔
1639
                    program: this,
1640
                    file: file,
1641
                    outputPath: this.getOutputPath(file, stagingDir),
1642
                    processedFiles: new Set<SerializedFile>()
1643
                });
1644

1645
                await this.plugins.emitAsync('writeFile', event);
1,103✔
1646

1647
                await this.plugins.emitAsync('afterWriteFile', event);
1,103✔
1648
            })
1649
        );
1650

1651
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
358✔
1652
    }
1653

1654
    private buildPipeline = new ActionPipeline();
1,841✔
1655

1656
    /**
1657
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
1658
     * @param options the list of options used to build the program
1659
     */
1660
    public async build(options?: ProgramBuildOptions) {
1661
        //run a single build at a time
1662
        await this.buildPipeline.run(async () => {
358✔
1663
            const stagingDir = this.getStagingDir(options?.stagingDir);
358✔
1664

1665
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
358✔
1666
                program: this,
1667
                editor: this.editor,
1668
                files: options?.files ?? Object.values(this.files)
2,148✔
1669
            });
1670

1671
            //prepare the program (and files) for building
1672
            event.files = await this.prepare(event.files);
358✔
1673

1674
            //stage the entire program
1675
            const serializedFilesByFile = await this.serialize(event.files);
358✔
1676

1677
            await this.write(stagingDir, serializedFilesByFile);
358✔
1678

1679
            await this.plugins.emitAsync('afterBuildProgram', event);
358✔
1680

1681
            //undo all edits for the program
1682
            this.editor.undoAll();
358✔
1683
            //undo all edits for each file
1684
            for (const file of event.files) {
358✔
1685
                file.editor.undoAll();
726✔
1686
            }
1687
        });
1688
    }
1689

1690
    /**
1691
     * Find a list of files in the program that have a function with the given name (case INsensitive)
1692
     */
1693
    public findFilesForFunction(functionName: string) {
1694
        const files = [] as BscFile[];
7✔
1695
        const lowerFunctionName = functionName.toLowerCase();
7✔
1696
        //find every file with this function defined
1697
        for (const file of Object.values(this.files)) {
7✔
1698
            if (isBrsFile(file)) {
25✔
1699
                //TODO handle namespace-relative function calls
1700
                //if the file has a function with this name
1701
                // eslint-disable-next-line @typescript-eslint/dot-notation
1702
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
17✔
1703
                    files.push(file);
2✔
1704
                }
1705
            }
1706
        }
1707
        return files;
7✔
1708
    }
1709

1710
    /**
1711
     * Find a list of files in the program that have a class with the given name (case INsensitive)
1712
     */
1713
    public findFilesForClass(className: string) {
1714
        const files = [] as BscFile[];
7✔
1715
        const lowerClassName = className.toLowerCase();
7✔
1716
        //find every file with this class defined
1717
        for (const file of Object.values(this.files)) {
7✔
1718
            if (isBrsFile(file)) {
25✔
1719
                //TODO handle namespace-relative classes
1720
                //if the file has a function with this name
1721

1722
                // eslint-disable-next-line @typescript-eslint/dot-notation
1723
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
17✔
1724
                    files.push(file);
1✔
1725
                }
1726
            }
1727
        }
1728
        return files;
7✔
1729
    }
1730

1731
    public findFilesForNamespace(name: string) {
1732
        const files = [] as BscFile[];
7✔
1733
        const lowerName = name.toLowerCase();
7✔
1734
        //find every file with this class defined
1735
        for (const file of Object.values(this.files)) {
7✔
1736
            if (isBrsFile(file)) {
25✔
1737

1738
                // eslint-disable-next-line @typescript-eslint/dot-notation
1739
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
17✔
1740
                    const namespaceName = x.name.toLowerCase();
7✔
1741
                    return (
7✔
1742
                        //the namespace name matches exactly
1743
                        namespaceName === lowerName ||
9✔
1744
                        //the full namespace starts with the name (honoring the part boundary)
1745
                        namespaceName.startsWith(lowerName + '.')
1746
                    );
1747
                })) {
1748
                    files.push(file);
6✔
1749
                }
1750
            }
1751
        }
1752

1753
        return files;
7✔
1754
    }
1755

1756
    public findFilesForEnum(name: string) {
1757
        const files = [] as BscFile[];
8✔
1758
        const lowerName = name.toLowerCase();
8✔
1759
        //find every file with this enum defined
1760
        for (const file of Object.values(this.files)) {
8✔
1761
            if (isBrsFile(file)) {
26✔
1762
                // eslint-disable-next-line @typescript-eslint/dot-notation
1763
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
18✔
1764
                    files.push(file);
1✔
1765
                }
1766
            }
1767
        }
1768
        return files;
8✔
1769
    }
1770

1771
    private _manifest: Map<string, string>;
1772

1773
    /**
1774
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
1775
     * @param parsedManifest The manifest map to read from and modify
1776
     */
1777
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
1778
        // Lift the bs_consts defined in the manifest
1779
        let bsConsts = getBsConst(parsedManifest, false);
15✔
1780

1781
        // Override or delete any bs_consts defined in the bs config
1782
        for (const key in this.options?.manifest?.bs_const) {
15!
1783
            const value = this.options.manifest.bs_const[key];
3✔
1784
            if (value === null) {
3✔
1785
                bsConsts.delete(key);
1✔
1786
            } else {
1787
                bsConsts.set(key, value);
2✔
1788
            }
1789
        }
1790

1791
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
1792
        let constString = '';
15✔
1793
        for (const [key, value] of bsConsts) {
15✔
1794
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
8✔
1795
        }
1796

1797
        // Set the updated bs_const value
1798
        parsedManifest.set('bs_const', constString);
15✔
1799
    }
1800

1801
    /**
1802
     * Try to find and load the manifest into memory
1803
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
1804
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
1805
     */
1806
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
1,511✔
1807
        //if we already have a manifest instance, and should not replace...then don't replace
1808
        if (!replaceIfAlreadyLoaded && this._manifest) {
1,517!
UNCOV
1809
            return;
×
1810
        }
1811
        let manifestPath = manifestFileObj
1,517✔
1812
            ? manifestFileObj.src
1,517✔
1813
            : path.join(this.options.rootDir, 'manifest');
1814

1815
        try {
1,517✔
1816
            // we only load this manifest once, so do it sync to improve speed downstream
1817
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
1,517✔
1818
            const parsedManifest = parseManifest(contents);
15✔
1819
            this.buildBsConstsIntoParsedManifest(parsedManifest);
15✔
1820
            this._manifest = parsedManifest;
15✔
1821
        } catch (e) {
1822
            this._manifest = new Map();
1,502✔
1823
        }
1824
    }
1825

1826
    /**
1827
     * Get a map of the manifest information
1828
     */
1829
    public getManifest() {
1830
        if (!this._manifest) {
2,336✔
1831
            this.loadManifest();
1,510✔
1832
        }
1833
        return this._manifest;
2,336✔
1834
    }
1835

1836
    public dispose() {
1837
        this.plugins.emit('beforeProgramDispose', { program: this });
1,679✔
1838

1839
        for (let filePath in this.files) {
1,679✔
1840
            this.files[filePath]?.dispose?.();
2,044!
1841
        }
1842
        for (let name in this.scopes) {
1,679✔
1843
            this.scopes[name]?.dispose?.();
3,517!
1844
        }
1845
        this.globalScope?.dispose?.();
1,679!
1846
        this.dependencyGraph?.dispose?.();
1,679!
1847
    }
1848
}
1849

1850
export interface FileTranspileResult {
1851
    srcPath: string;
1852
    destPath: string;
1853
    pkgPath: string;
1854
    code: string;
1855
    map: string;
1856
    typedef: string;
1857
}
1858

1859

1860
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
1861
    constructor(
1862
        public program: Program,
2,375✔
1863
        public srcPath: string,
2,375✔
1864
        public destPath: string,
2,375✔
1865
        public data: LazyFileData,
2,375✔
1866
        public fileFactory: FileFactory
2,375✔
1867
    ) {
1868
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
2,375!
1869
    }
1870

1871
    public srcExtension: string;
1872

1873
    public files: TFile[] = [];
2,375✔
1874
}
1875

1876
export interface ProgramBuildOptions {
1877
    /**
1878
     * The directory where the final built files should be placed. This directory will be cleared before running
1879
     */
1880
    stagingDir?: string;
1881
    /**
1882
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
1883
     * Typically you will want to leave this blank
1884
     */
1885
    files?: BscFile[];
1886
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc