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

rokucommunity / brighterscript / #15916

13 May 2026 06:48PM UTC coverage: 86.923% (+0.02%) from 86.904%
#15916

push

web-flow
Merge 7fe8ea0ed into 9de11ed0c

15646 of 19004 branches covered (82.33%)

Branch coverage included in aggregate %.

16359 of 17816 relevant lines covered (91.82%)

27323.14 hits per line

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

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

68
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
69
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
70

71
export interface SignatureInfoObj {
72
    index: number;
73
    key: string;
74
    signature: SignatureInformation;
75
}
76

77
export class Program {
1✔
78
    constructor(
79
        /**
80
         * The root directory for this program
81
         */
82
        options: BsConfig,
83
        logger?: Logger,
84
        plugins?: PluginInterface,
85
        diagnosticsManager?: DiagnosticManager
86
    ) {
87
        this.options = util.normalizeConfig(options);
2,506✔
88
        this.logger = logger ?? createLogger(options);
2,506✔
89
        this.plugins = plugins || new PluginInterface([], { logger: this.logger });
2,506✔
90
        this.diagnostics = diagnosticsManager || new DiagnosticManager();
2,506✔
91

92
        //try to find a location for the diagnostic if it doesn't have one
93
        this.diagnostics.locationResolver = (args) => {
2,506✔
94

95
            //find the first xml scope for this diagnostic
96
            for (let context of args.contexts) {
6✔
97
                if (isXmlScope(context.scope) && isXmlFile(context.scope.xmlFile)) {
1!
98
                    return util.createLocation(0, 0, 0, 100, context.scope.xmlFile.srcPath);
1✔
99
                }
100
            }
101

102
            //we couldn't find an xml scope for this, so try to find the manifest file instead
103
            const manifest = this.getFile('manifest', false);
5✔
104
            if (manifest) {
5✔
105
                return util.createLocation(0, 0, 0, 100, manifest.srcPath);
3✔
106
            }
107

108
            //if we still don't have a manifest, try to find the first file in the program
109
            for (const key in this.files) {
2✔
110
                if (isBrsFile(this.files[key]) || isXmlFile(this.files[key])) {
2!
111
                    return util.createLocation(0, 0, 0, 100, this.files[key].srcPath);
2✔
112
                }
113
            }
114

115
            this.logger.warn(`Unable to find a location for the diagnostic.`, args);
×
116

117
            //we couldn't find any locations for the file, so just return undefined
118
            return undefined;
×
119
        };
120

121
        // initialize the diagnostics Manager
122
        this.diagnostics.logger = this.logger;
2,506✔
123
        this.diagnostics.options = this.options;
2,506✔
124
        this.diagnostics.program = this;
2,506✔
125

126
        //inject the bsc plugin as the first plugin in the stack.
127
        this.plugins.addFirst(new BscPlugin());
2,506✔
128

129
        //normalize the root dir path
130
        this.options.rootDir = util.getRootDir(this.options);
2,506✔
131

132
        this.createGlobalScope();
2,506✔
133

134
        this.fileFactory = new FileFactory(this);
2,506✔
135
    }
136

137
    public options: FinalizedBsConfig;
138
    public logger: Logger;
139

140
    /**
141
     * 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`)
142
     */
143
    public editor = new Editor();
2,506✔
144

145
    /**
146
     * A factory that creates `File` instances
147
     */
148
    private fileFactory: FileFactory;
149

150
    private createGlobalScope() {
151
        //create the 'global' scope
152
        this.globalScope = new Scope('global', this, 'scope:global');
2,506✔
153
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
2,506✔
154
        this.scopes.global = this.globalScope;
2,506✔
155

156
        this.populateGlobalSymbolTable();
2,506✔
157
        this.globalScope.symbolTable.addSibling(this.componentsTable);
2,506✔
158

159
        //hardcode the files list for global scope to only contain the global file
160
        this.globalScope.getAllFiles = () => [globalFile];
20,722✔
161
        globalFile.isValidated = true;
2,506✔
162
        this.globalScope.validate();
2,506✔
163

164
        //TODO we might need to fix this because the isValidated clears stuff now
165
        (this.globalScope as any).isValidated = true;
2,506✔
166
    }
167

168

169
    private recursivelyAddNodeToSymbolTable(nodeData: SGNodeData) {
170
        if (!nodeData) {
468,622!
171
            return;
×
172
        }
173
        let nodeType: ComponentType;
174
        const nodeName = util.getSgNodeTypeName(nodeData.name);
468,622✔
175
        if (!this.globalScope.symbolTable.hasSymbol(nodeName, SymbolTypeFlag.typetime)) {
468,622✔
176
            let parentNode: ComponentType;
177
            if (nodeData.extends) {
243,082✔
178
                const parentNodeData = nodes[nodeData.extends.name.toLowerCase()];
225,540✔
179
                try {
225,540✔
180
                    parentNode = this.recursivelyAddNodeToSymbolTable(parentNodeData);
225,540✔
181
                } catch (error) {
182
                    this.logger.error(error, nodeData);
×
183
                }
184
            }
185
            nodeType = new ComponentType(nodeData.name, parentNode);
243,082✔
186
            nodeType.addBuiltInInterfaces();
243,082✔
187
            nodeType.isBuiltIn = true;
243,082✔
188
            if (nodeData.name === 'Node') {
243,082✔
189
                // Add `roSGNode` as shorthand for `roSGNodeNode`
190
                this.globalScope.symbolTable.addSymbol('roSGNode', { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
2,506✔
191
            }
192
            this.globalScope.symbolTable.addSymbol(nodeName, { description: nodeData.description, isBuiltIn: true }, nodeType, SymbolTypeFlag.typetime);
243,082✔
193
        } else {
194
            nodeType = this.globalScope.symbolTable.getSymbolType(nodeName, { flags: SymbolTypeFlag.typetime }) as ComponentType;
225,540✔
195
        }
196

197
        return nodeType;
468,622✔
198
    }
199
    /**
200
     * Do all setup required for the global symbol table.
201
     */
202
    private populateGlobalSymbolTable() {
203
        //Setup primitive types in global symbolTable
204

205
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
2,506✔
206

207
        this.globalScope.symbolTable.addSymbol('boolean', builtInSymbolData, BooleanType.instance, SymbolTypeFlag.typetime);
2,506✔
208
        this.globalScope.symbolTable.addSymbol('double', builtInSymbolData, DoubleType.instance, SymbolTypeFlag.typetime);
2,506✔
209
        this.globalScope.symbolTable.addSymbol('dynamic', builtInSymbolData, DynamicType.instance, SymbolTypeFlag.typetime);
2,506✔
210
        this.globalScope.symbolTable.addSymbol('float', builtInSymbolData, FloatType.instance, SymbolTypeFlag.typetime);
2,506✔
211
        this.globalScope.symbolTable.addSymbol('function', builtInSymbolData, FunctionType.instance, SymbolTypeFlag.typetime);
2,506✔
212
        this.globalScope.symbolTable.addSymbol('integer', builtInSymbolData, IntegerType.instance, SymbolTypeFlag.typetime);
2,506✔
213
        this.globalScope.symbolTable.addSymbol('longinteger', builtInSymbolData, LongIntegerType.instance, SymbolTypeFlag.typetime);
2,506✔
214
        this.globalScope.symbolTable.addSymbol('object', builtInSymbolData, ObjectType.instance, SymbolTypeFlag.typetime);
2,506✔
215
        this.globalScope.symbolTable.addSymbol('string', builtInSymbolData, StringType.instance, SymbolTypeFlag.typetime);
2,506✔
216
        this.globalScope.symbolTable.addSymbol('void', builtInSymbolData, VoidType.instance, SymbolTypeFlag.typetime);
2,506✔
217

218
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
709,505✔
219

220
        for (const callable of globalCallables) {
2,506✔
221
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
192,962✔
222
        }
223

224
        for (const ifaceData of Object.values(interfaces) as BRSInterfaceData[]) {
2,506✔
225
            const ifaceType = new InterfaceType(ifaceData.name);
228,046✔
226
            ifaceType.addBuiltInInterfaces();
228,046✔
227
            ifaceType.isBuiltIn = true;
228,046✔
228
            this.globalScope.symbolTable.addSymbol(ifaceData.name, { ...builtInSymbolData, description: ifaceData.description }, ifaceType, SymbolTypeFlag.typetime);
228,046✔
229
        }
230

231
        for (const componentData of Object.values(components) as BRSComponentData[]) {
2,506✔
232
            let roComponentType: BscType;
233
            const lowerComponentName = componentData.name.toLowerCase();
167,902✔
234

235
            if (lowerComponentName === 'rosgnode') {
167,902✔
236
                // we will add `roSGNode` as shorthand for `roSGNodeNode`, since all roSgNode components are SceneGraph nodes
237
                continue;
2,506✔
238
            }
239
            if (lowerComponentName === 'rofunction') {
165,396✔
240
                roComponentType = new roFunctionType();
2,506✔
241
            } else {
242
                roComponentType = new InterfaceType(componentData.name);
162,890✔
243
            }
244
            roComponentType.addBuiltInInterfaces();
165,396✔
245
            roComponentType.isBuiltIn = true;
165,396✔
246
            this.globalScope.symbolTable.addSymbol(componentData.name, { ...builtInSymbolData, description: componentData.description }, roComponentType, SymbolTypeFlag.typetime);
165,396✔
247
        }
248

249
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
2,506✔
250
            this.recursivelyAddNodeToSymbolTable(nodeData);
243,082✔
251
        }
252

253
        for (const eventData of Object.values(events) as BRSEventData[]) {
2,506✔
254
            const eventType = new InterfaceType(eventData.name);
45,108✔
255
            eventType.addBuiltInInterfaces();
45,108✔
256
            eventType.isBuiltIn = true;
45,108✔
257
            this.globalScope.symbolTable.addSymbol(eventData.name, { ...builtInSymbolData, description: eventData.description }, eventType, SymbolTypeFlag.typetime);
45,108✔
258
        }
259

260
    }
261

262
    /**
263
     * A graph of all files and their dependencies.
264
     * For example:
265
     *      File.xml -> [lib1.brs, lib2.brs]
266
     *      lib2.brs -> [lib3.brs] //via an import statement
267
     */
268
    private dependencyGraph = new DependencyGraph();
2,506✔
269

270
    public diagnostics: DiagnosticManager;
271

272
    /**
273
     * A scope that contains all built-in global functions.
274
     * All scopes should directly or indirectly inherit from this scope
275
     */
276
    public globalScope: Scope = undefined as any;
2,506✔
277

278
    /**
279
     * Plugins which can provide extra diagnostics or transform AST
280
     */
281
    public plugins: PluginInterface;
282

283
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
2,506✔
284

285
    private currentScopeValidationOptions: ScopeValidationOptions;
286

287
    /**
288
     *  Map of typetime symbols which depend upon the key symbol
289
     */
290
    private symbolDependencies = new Map<string, Set<string>>();
2,506✔
291

292

293
    /**
294
     * Symbol Table for storing custom component types
295
     * This is a sibling to the global table (as Components can be used/referenced anywhere)
296
     * Keeping custom components out of the global table and in a specific symbol table
297
     * compartmentalizes their use
298
     */
299
    private componentsTable = new SymbolTable('Custom Components');
2,506✔
300

301
    public addFileSymbolInfo(file: BrsFile) {
302
        this.fileSymbolInformation.set(file.pkgPath, {
2,389✔
303
            provides: file.providedSymbols,
304
            requires: file.requiredSymbols
305
        });
306
    }
307

308
    public getFileSymbolInfo(file: BrsFile) {
309
        return this.fileSymbolInformation.get(file.pkgPath);
2,394✔
310
    }
311

312
    /**
313
     * The path to bslib.brs (the BrightScript runtime for certain BrighterScript features)
314
     */
315
    public get bslibPkgPath() {
316
        //if there's an aliased (preferred) version of bslib from roku_modules loaded into the program, use that
317
        if (this.getFile(bslibAliasedRokuModulesPkgPath)) {
3,128✔
318
            return bslibAliasedRokuModulesPkgPath;
11✔
319

320
            //if there's a non-aliased version of bslib from roku_modules, use that
321
        } else if (this.getFile(bslibNonAliasedRokuModulesPkgPath)) {
3,117✔
322
            return bslibNonAliasedRokuModulesPkgPath;
24✔
323

324
            //default to the embedded version
325
        } else {
326
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
3,093✔
327
        }
328
    }
329

330
    public get bslibPrefix() {
331
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
2,263✔
332
            return 'rokucommunity_bslib';
18✔
333
        } else {
334
            return 'bslib';
2,245✔
335
        }
336
    }
337

338

339
    /**
340
     * A map of every file loaded into this program, indexed by its original file location
341
     */
342
    public files = {} as Record<string, BscFile>;
2,506✔
343
    /**
344
     * A map of every file loaded into this program, indexed by its destPath
345
     */
346
    private destMap = new Map<string, BscFile>();
2,506✔
347
    /**
348
     * Plugins can contribute multiple virtual files for a single physical file.
349
     * This collection links the virtual files back to the physical file that produced them.
350
     * The key is the standardized and lower-cased srcPath
351
     */
352
    private fileClusters = new Map<string, BscFile[]>();
2,506✔
353

354
    /**
355
     * Map from a lower-cased namespace name part to the set of `BrsFile`s that contribute
356
     * to it. Built lazily, invalidated whenever any file is added, removed, or re-parsed
357
     * (`setFile` and `removeFile` both clear it).
358
     *
359
     * Used by `ScopeNamespaceLookup` to resolve a namespace name to its contributing
360
     * files in O(1), then intersect against the scope's file set.
361
     */
362
    private namespaceContributors: Map<string, Set<BrsFile>> | undefined;
363

364
    /**
365
     * Look up the set of `BrsFile`s that declare any part of the given namespace name
366
     * (lowercased). Returns `undefined` when no file contributes.
367
     * @internal
368
     */
369
    protected getNamespaceContributors(namespaceNameLower: string): Set<BrsFile> | undefined {
370
        if (!this.namespaceContributors) {
1,269✔
371
            this.namespaceContributors = this.buildNamespaceContributors();
407✔
372
        }
373
        return this.namespaceContributors.get(namespaceNameLower);
1,269✔
374
    }
375

376
    private buildNamespaceContributors(): Map<string, Set<BrsFile>> {
377
        const contributors = new Map<string, Set<BrsFile>>();
407✔
378
        for (const file of Object.values(this.files)) {
407✔
379
            if (isBrsFile(file)) {
1,040✔
380
                // eslint-disable-next-line @typescript-eslint/dot-notation
381
                for (const nameLower of file['getNamespaceContributions']().keys()) {
774✔
382
                    let set = contributors.get(nameLower);
1,160✔
383
                    if (!set) {
1,160✔
384
                        set = new Set<BrsFile>();
663✔
385
                        contributors.set(nameLower, set);
663✔
386
                    }
387
                    set.add(file);
1,160✔
388
                }
389
            }
390
        }
391
        return contributors;
407✔
392
    }
393

394
    /**
395
     * Cached slow-path namespace aggregates, keyed by `(nameLower, sorted-contributor-pkgPaths)`.
396
     * Two scopes with the same in-scope file set for a multi-contributor namespace share
397
     * the same aggregate object (and therefore the same merged statement collections and
398
     * symbolTable instance). Built lazily, invalidated alongside `namespaceContributors`.
399
     *
400
     * The aggregate is stored as a `NamespaceContainer` whose `namespaces` field is an
401
     * empty Map: scopes always wrap the aggregate before returning to plugins, and the
402
     * wrapper supplies its own scope-filtered children. Plugins never see the aggregate
403
     * directly.
404
     */
405
    private aggregateNamespaceContainerCache: Map<string, NamespaceContainer> | undefined;
406

407
    /**
408
     * Get or build the shared aggregate for a namespace whose in-scope contributors
409
     * include more than one file. The aggregate's heavy fields are computed once per
410
     * unique `(nameLower, contributing-files-set)` and reused across every scope that
411
     * sees the same set.
412
     * @internal
413
     */
414
    protected getAggregateNamespaceContainer(nameLower: string, contributions: NamespaceFileContribution[]): NamespaceContainer {
415
        if (!this.aggregateNamespaceContainerCache) {
411✔
416
            this.aggregateNamespaceContainerCache = new Map<string, NamespaceContainer>();
55✔
417
        }
418
        //sorted pkgPaths ensure two scopes with the same contributor set hit the same key
419
        const key = nameLower + '|' + contributions
411✔
420
            .map(c => c.file.pkgPath.toLowerCase())
830✔
421
            .sort()
422
            .join('|');
423
        let aggregate = this.aggregateNamespaceContainerCache.get(key);
411✔
424
        if (!aggregate) {
411✔
425
            aggregate = this.buildAggregateNamespaceContainer(contributions);
263✔
426
            this.aggregateNamespaceContainerCache.set(key, aggregate);
263✔
427
        }
428
        return aggregate;
411✔
429
    }
430

431
    private buildAggregateNamespaceContainer(contributions: NamespaceFileContribution[]): NamespaceContainer {
432
        const first = contributions[0];
263✔
433
        //field order matches the NamespaceContainer interface declaration so aggregates
434
        //share a single V8 hidden class with the per-scope wrapper containers
435
        const aggregate: NamespaceContainer = {
263✔
436
            file: first.file,
437
            fullName: first.fullName,
438
            nameRange: first.nameRange,
439
            lastPartName: first.lastPartName,
440
            namespaces: new Map(),
441
            namespaceStatements: undefined,
442
            statements: undefined,
443
            classStatements: undefined,
444
            functionStatements: undefined,
445
            enumStatements: undefined,
446
            constStatements: undefined,
447
            symbolTable: undefined
448
        };
449
        for (const contribution of contributions) {
263✔
450
            if (contribution.namespaceStatements?.length) {
532✔
451
                (aggregate.namespaceStatements ??= []).push(...contribution.namespaceStatements);
515✔
452
            }
453
            if (contribution.statements?.length) {
532✔
454
                (aggregate.statements ??= []).push(...contribution.statements);
515✔
455
            }
456
            if (contribution.classStatements) {
532✔
457
                aggregate.classStatements = { ...(aggregate.classStatements ?? {}), ...contribution.classStatements };
23✔
458
            }
459
            if (contribution.functionStatements) {
532✔
460
                aggregate.functionStatements = { ...(aggregate.functionStatements ?? {}), ...contribution.functionStatements };
265✔
461
            }
462
            if (contribution.enumStatements) {
532✔
463
                aggregate.enumStatements ??= new Map();
9!
464
                for (const [key, value] of contribution.enumStatements) {
9✔
465
                    aggregate.enumStatements.set(key, value);
9✔
466
                }
467
            }
468
            if (contribution.constStatements) {
532✔
469
                aggregate.constStatements ??= new Map();
214✔
470
                for (const [key, value] of contribution.constStatements) {
214✔
471
                    aggregate.constStatements.set(key, value);
218✔
472
                }
473
            }
474
            if (contribution.symbolTable) {
532✔
475
                aggregate.symbolTable ??= new SymbolTable(`Namespace Multi-File Aggregate: '${first.fullName}'`);
515✔
476
                aggregate.symbolTable.mergeSymbolTable(contribution.symbolTable);
515✔
477
            }
478
        }
479
        return aggregate;
263✔
480
    }
481

482
    /**
483
     * Invalidate the program-level namespace contributors map and the slow-path aggregate
484
     * cache. Called by `setFile` and `removeFile`; downstream scope namespace lookups
485
     * already rebuild via the dependency-graph invalidation chain, so this only needs
486
     * to drop the cached maps.
487
     */
488
    private invalidateNamespaceContributorCache() {
489
        this.namespaceContributors = undefined;
3,564✔
490
        this.aggregateNamespaceContainerCache = undefined;
3,564✔
491
    }
492

493
    private scopes = {} as Record<string, Scope>;
2,506✔
494

495
    protected addScope(scope: Scope) {
496
        this.scopes[scope.name] = scope;
2,679✔
497
        delete this.sortedScopeNames;
2,679✔
498
    }
499

500
    protected removeScope(scope: Scope) {
501
        if (this.scopes[scope.name]) {
16!
502
            delete this.scopes[scope.name];
16✔
503
            delete this.sortedScopeNames;
16✔
504
        }
505
    }
506

507
    /**
508
     * A map of every component currently loaded into the program, indexed by the component name.
509
     * It is a compile-time error to have multiple components with the same name. However, we store an array of components
510
     * by name so we can provide a better developer expreience. You shouldn't be directly accessing this array,
511
     * but if you do, only ever use the component at index 0.
512
     */
513
    private components = {} as Record<string, { file: XmlFile; scope: XmlScope }[]>;
2,506✔
514

515
    /**
516
     * Get the component with the specified name
517
     */
518
    public getComponent(componentName: string) {
519
        if (componentName) {
3,524✔
520
            //return the first compoment in the list with this name
521
            //(components are ordered in this list by destPath to ensure consistency)
522
            return this.components[componentName.toLowerCase()]?.[0];
3,485✔
523
        } else {
524
            return undefined;
39✔
525
        }
526
    }
527

528
    /**
529
     * Get the sorted names of custom components
530
     */
531
    public getSortedComponentNames() {
532
        const componentNames = Object.keys(this.components);
2,120✔
533
        componentNames.sort((a, b) => {
2,120✔
534
            if (a < b) {
873✔
535
                return -1;
330✔
536
            } else if (b < a) {
543!
537
                return 1;
543✔
538
            }
539
            return 0;
×
540
        });
541
        return componentNames;
2,120✔
542
    }
543

544
    /**
545
     * Keeps a set of all the components that need to have their types updated during the current validation cycle
546
     * Map <componentKey, componentName>
547
     */
548
    private componentSymbolsToUpdate = new Map<string, string>();
2,506✔
549

550
    /**
551
     * Register (or replace) the reference to a component in the component map
552
     */
553
    private registerComponent(xmlFile: XmlFile, scope: XmlScope) {
554
        const key = this.getComponentKey(xmlFile);
530✔
555
        if (!this.components[key]) {
530✔
556
            this.components[key] = [];
513✔
557
        }
558
        this.components[key].push({
530✔
559
            file: xmlFile,
560
            scope: scope
561
        });
562
        this.components[key].sort((a, b) => {
530✔
563
            const pathA = a.file.destPath.toLowerCase();
5✔
564
            const pathB = b.file.destPath.toLowerCase();
5✔
565
            if (pathA < pathB) {
5✔
566
                return -1;
1✔
567
            } else if (pathA > pathB) {
4!
568
                return 1;
4✔
569
            }
570
            return 0;
×
571
        });
572
        this.syncComponentDependencyGraph(this.components[key]);
530✔
573
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
530✔
574
    }
575

576
    /**
577
     * Remove the specified component from the components map
578
     */
579
    private unregisterComponent(xmlFile: XmlFile) {
580
        const key = this.getComponentKey(xmlFile);
16✔
581
        const arr = this.components[key] || [];
16!
582
        for (let i = 0; i < arr.length; i++) {
16✔
583
            if (arr[i].file === xmlFile) {
16!
584
                arr.splice(i, 1);
16✔
585
                break;
16✔
586
            }
587
        }
588

589
        this.syncComponentDependencyGraph(arr);
16✔
590
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
16✔
591
    }
592

593
    /**
594
     * Adds a component described in an XML to the set of components that needs to be updated this validation cycle.
595
     * @param xmlFile XML file with <component> tag
596
     */
597
    private addDeferredComponentTypeSymbolCreation(xmlFile: XmlFile) {
598
        const componentKey = this.getComponentKey(xmlFile);
1,092✔
599
        const componentName = xmlFile.componentName?.text;
1,092✔
600
        if (this.componentSymbolsToUpdate.has(componentKey)) {
1,092✔
601
            return;
566✔
602
        }
603
        this.componentSymbolsToUpdate.set(componentKey, componentName);
526✔
604
    }
605

606
    private getComponentKey(xmlFile: XmlFile) {
607
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,638✔
608
    }
609

610
    /**
611
     * Resolves symbol table with the first component in this.components to have the same name as the component in the file
612
     * @param componentKey key getting a component from `this.components`
613
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
614
     */
615
    private updateComponentSymbolInGlobalScope(componentKey: string, componentName: string) {
616
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
477✔
617
        if (!symbolName) {
477✔
618
            return;
7✔
619
        }
620
        const components = this.components[componentKey] || [];
470!
621
        const previousComponentType = this.componentsTable.getSymbolType(symbolName, { flags: SymbolTypeFlag.typetime });
470✔
622
        // Remove any existing symbols that match
623
        this.componentsTable.removeSymbol(symbolName);
470✔
624
        if (components.length > 0) {
470✔
625
            // There is a component that can be added - use it.
626
            const componentScope = components[0].scope;
469✔
627

628
            this.componentsTable.removeSymbol(symbolName);
469✔
629
            componentScope.linkSymbolTable();
469✔
630
            const componentType = componentScope.getComponentType();
469✔
631
            if (componentType) {
469!
632
                this.componentsTable.addSymbol(symbolName, {}, componentType, SymbolTypeFlag.typetime);
469✔
633
            }
634
            const typeData = {};
469✔
635
            const isSameAsPrevious = previousComponentType && componentType.isEqual(previousComponentType, typeData);
469✔
636
            const isComponentTypeDifferent = !previousComponentType || isReferenceType(previousComponentType) || !isSameAsPrevious;
469✔
637
            componentScope.unlinkSymbolTable();
469✔
638
            return isComponentTypeDifferent;
469✔
639

640
        }
641
        // There was a previous component type, but no new one, so it's different
642
        return !!previousComponentType;
1✔
643
    }
644

645
    /**
646
     * 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
647
     * This is so on a first validation, these types can be resolved in teh future (eg. when the actual component is created)
648
     * If we don't add reference types at this top level, they will be created at the file level, and will never get resolved
649
     * @param componentKey key getting a component from `this.components`
650
     * @param componentName the unprefixed name of the component that will be added (e.g. 'MyLabel' NOT 'roSgNodeMyLabel')
651
     */
652
    private addComponentReferenceType(componentKey: string, componentName: string) {
653
        const symbolName = componentName ? util.getSgNodeTypeName(componentName) : undefined;
473✔
654
        if (!symbolName) {
473✔
655
            return;
7✔
656
        }
657
        const components = this.components[componentKey] || [];
466!
658

659
        if (components.length > 0) {
466✔
660
            // There is a component that can be added,
661
            if (!this.componentsTable.hasSymbol(symbolName, SymbolTypeFlag.typetime)) {
465✔
662
                // it doesn't already exist in the table
663
                const componentRefType = new ReferenceType(symbolName, symbolName, SymbolTypeFlag.typetime, () => this.componentsTable);
8,296✔
664
                if (componentRefType) {
458!
665
                    this.componentsTable.addSymbol(symbolName, {}, componentRefType, SymbolTypeFlag.typetime);
458✔
666
                }
667
            }
668
        } else {
669
            // there is no component. remove from table
670
            this.componentsTable.removeSymbol(symbolName);
1✔
671
        }
672
    }
673

674
    /**
675
     * re-attach the dependency graph with a new key for any component who changed
676
     * their position in their own named array (only matters when there are multiple
677
     * components with the same name)
678
     */
679
    private syncComponentDependencyGraph(components: Array<{ file: XmlFile; scope: XmlScope }>) {
680
        //reattach every dependency graph
681
        for (let i = 0; i < components.length; i++) {
546✔
682
            const { file, scope } = components[i];
536✔
683

684
            //attach (or re-attach) the dependencyGraph for every component whose position changed
685
            if (file.dependencyGraphIndex !== i) {
536✔
686
                file.dependencyGraphIndex = i;
532✔
687
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies);
532✔
688
                file.attachDependencyGraph(this.dependencyGraph);
532✔
689
                scope.attachDependencyGraph(this.dependencyGraph);
532✔
690
            }
691
        }
692
    }
693

694
    /**
695
     * Get a list of all files that are included in the project but are not referenced
696
     * by any scope in the program.
697
     */
698
    public getUnreferencedFiles() {
699
        let result = [] as BscFile[];
×
700
        for (let filePath in this.files) {
×
701
            let file = this.files[filePath];
×
702
            //is this file part of a scope
703
            if (!this.getFirstScopeForFile(file)) {
×
704
                //no scopes reference this file. add it to the list
705
                result.push(file);
×
706
            }
707
        }
708
        return result;
×
709
    }
710

711
    /**
712
     * Get the list of errors for the entire program.
713
     */
714
    public getDiagnostics() {
715
        return this.diagnostics.getDiagnostics();
1,667✔
716
    }
717

718
    /**
719
     * Determine if the specified file is loaded in this program right now.
720
     * @param filePath the absolute or relative path to the file
721
     * @param normalizePath should the provided path be normalized before use
722
     */
723
    public hasFile(filePath: string, normalizePath = true) {
3,623✔
724
        return !!this.getFile(filePath, normalizePath);
3,623✔
725
    }
726

727
    /**
728
     * roku filesystem is case INsensitive, so find the scope by key case insensitive
729
     * @param scopeName xml scope names are their `destPath`. Source scope is stored with the key `"source"`
730
     */
731
    public getScopeByName(scopeName: string): Scope | undefined {
732
        if (!scopeName) {
83!
733
            return undefined;
×
734
        }
735
        //most scopes are xml file pkg paths. however, the ones that are not are single names like "global" and "scope",
736
        //so it's safe to run the standardizePkgPath method
737
        scopeName = s`${scopeName}`;
83✔
738
        let key = Object.keys(this.scopes).find(x => x.toLowerCase() === scopeName.toLowerCase());
192✔
739
        return this.scopes[key!];
83✔
740
    }
741

742
    /**
743
     * Return all scopes
744
     */
745
    public getScopes() {
746
        return Object.values(this.scopes);
13✔
747
    }
748

749
    /**
750
     * Find the scope for the specified component
751
     */
752
    public getComponentScope(componentName: string) {
753
        return this.getComponent(componentName)?.scope;
981✔
754
    }
755

756
    /**
757
     * Update internal maps with this file reference
758
     */
759
    private assignFile<T extends BscFile = BscFile>(file: T) {
760
        const fileAddEvent: BeforeAddFileEvent = {
3,321✔
761
            file: file,
762
            program: this
763
        };
764

765
        this.plugins.emit('beforeAddFile', fileAddEvent);
3,321✔
766

767
        this.files[file.srcPath.toLowerCase()] = file;
3,321✔
768
        this.destMap.set(file.destPath.toLowerCase(), file);
3,321✔
769

770
        this.plugins.emit('afterAddFile', fileAddEvent);
3,321✔
771

772
        return file;
3,321✔
773
    }
774

775
    /**
776
     * Remove this file from internal maps
777
     */
778
    private unassignFile<T extends BscFile = BscFile>(file: T) {
779
        delete this.files[file.srcPath.toLowerCase()];
249✔
780
        this.destMap.delete(file.destPath.toLowerCase());
249✔
781
        return file;
249✔
782
    }
783

784
    /**
785
     * Load a file into the program. If that file already exists, it is replaced.
786
     * If file contents are provided, those are used, Otherwise, the file is loaded from the file system
787
     * @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:/`)
788
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
789
     */
790
    public setFile<T extends BscFile>(srcDestOrPkgPath: string, fileData?: FileData): T;
791
    /**
792
     * Load a file into the program. If that file already exists, it is replaced.
793
     * @param fileEntry an object that specifies src and dest for the file.
794
     * @param fileData the file contents. omit or pass `undefined` to prevent loading the data at this time
795
     */
796
    public setFile<T extends BscFile>(fileEntry: FileObj, fileData: FileData): T;
797
    public setFile<T extends BscFile>(fileParam: FileObj | string, fileData: FileData): T {
798
        //normalize the file paths
799
        const { srcPath, destPath } = this.getPaths(fileParam, this.options.rootDir);
3,317✔
800

801
        //namespace contributions for the new/replaced file may differ; force the
802
        //program-level contributors map to rebuild on next query
803
        this.invalidateNamespaceContributorCache();
3,317✔
804

805
        let file = this.logger.time(LogLevel.debug, ['Program.setFile()', chalk.green(srcPath)], () => {
3,317✔
806
            //if the file is already loaded, remove it
807
            if (this.hasFile(srcPath)) {
3,317✔
808
                this.removeFile(srcPath, true, true);
225✔
809
            }
810

811
            const data = new LazyFileData(fileData);
3,317✔
812

813
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
3,317✔
814

815
            this.plugins.emit('beforeProvideFile', event);
3,317✔
816
            this.plugins.emit('provideFile', event);
3,317✔
817
            this.plugins.emit('afterProvideFile', event);
3,317✔
818

819
            //if no files were provided, create a AssetFile to represent it.
820
            if (event.files.length === 0) {
3,317✔
821
                event.files.push(
45✔
822
                    this.fileFactory.AssetFile({
823
                        srcPath: event.srcPath,
824
                        destPath: event.destPath,
825
                        pkgPath: event.destPath,
826
                        data: data
827
                    })
828
                );
829
            }
830

831
            //find the file instance for the srcPath that triggered this action.
832
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
3,317✔
833

834
            if (!primaryFile) {
3,317!
835
                throw new Error(`No file provided for srcPath '${srcPath}'. Instead, received ${JSON.stringify(event.files.map(x => ({
×
836
                    type: x.type,
837
                    srcPath: x.srcPath,
838
                    destPath: x.destPath
839
                })))}`);
840
            }
841

842
            //link the virtual files to the primary file
843
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
3,317!
844

845
            for (const file of event.files) {
3,317✔
846
                file.srcPath = s(file.srcPath);
3,321✔
847
                if (file.destPath) {
3,321!
848
                    file.destPath = s`${util.replaceCaseInsensitive(file.destPath, this.options.rootDir, '')}`;
3,321✔
849
                }
850
                if (file.pkgPath) {
3,321✔
851
                    file.pkgPath = s`${util.replaceCaseInsensitive(file.pkgPath, this.options.rootDir, '')}`;
3,317✔
852
                } else {
853
                    file.pkgPath = file.destPath;
4✔
854
                }
855
                file.excludeFromOutput = file.excludeFromOutput === true;
3,321✔
856

857
                //set the dependencyGraph key for every file to its destPath
858
                file.dependencyGraphKey = file.destPath.toLowerCase();
3,321✔
859

860
                this.assignFile(file);
3,321✔
861

862
                //register a callback anytime this file's dependencies change
863
                if (typeof file.onDependenciesChanged === 'function') {
3,321✔
864
                    file.disposables ??= [];
3,268!
865
                    file.disposables.push(
3,268✔
866
                        this.dependencyGraph.onchange(file.dependencyGraphKey, file.onDependenciesChanged.bind(file))
867
                    );
868
                }
869

870
                //register this file (and its dependencies) with the dependency graph
871
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
3,321✔
872

873
                //if this is a `source` file, add it to the source scope's dependency list
874
                if (this.isSourceBrsFile(file)) {
3,321✔
875
                    this.createSourceScope();
2,285✔
876
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
2,285✔
877
                }
878

879
                //if this is an xml file in the components folder, register it as a component
880
                if (this.isComponentsXmlFile(file)) {
3,321✔
881
                    this.plugins.emit('beforeProvideScope', {
530✔
882
                        program: this,
883
                        scope: undefined
884
                    });
885
                    //create a new scope for this xml file
886
                    let scope = new XmlScope(file, this);
530✔
887
                    this.addScope(scope);
530✔
888

889
                    //register this componet now that we have parsed it and know its component name
890
                    this.registerComponent(file, scope);
530✔
891
                    this.plugins.emit('provideScope', {
530✔
892
                        program: this,
893
                        scope: scope
894
                    });
895

896
                    //notify plugins that the scope is created and the component is registered
897
                    this.plugins.emit('afterProvideScope', {
530✔
898
                        program: this,
899
                        scope: scope
900
                    });
901
                }
902
            }
903

904
            return primaryFile;
3,317✔
905
        });
906
        return file as T;
3,317✔
907
    }
908

909
    /**
910
     * Given a srcPath, a destPath, or both, resolve whichever is missing, relative to rootDir.
911
     * @param fileParam an object representing file paths
912
     * @param rootDir must be a pre-normalized path
913
     */
914
    private getPaths(fileParam: string | FileObj | { srcPath?: string; pkgPath?: string }, rootDir: string) {
915
        let srcPath: string | undefined;
916
        let destPath: string | undefined;
917

918
        assert.ok(fileParam, 'fileParam is required');
3,569✔
919

920
        //lift the path vars from the incoming param
921
        if (typeof fileParam === 'string') {
3,569✔
922
            fileParam = this.removePkgPrefix(fileParam);
2,961✔
923
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
2,961✔
924
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
2,961✔
925
        } else {
926
            let param: any = fileParam;
608✔
927

928
            if (param.src) {
608✔
929
                srcPath = s`${param.src}`;
607✔
930
            }
931
            if (param.srcPath) {
608!
932
                srcPath = s`${param.srcPath}`;
×
933
            }
934
            if (param.dest) {
608✔
935
                destPath = s`${this.removePkgPrefix(param.dest)}`;
607✔
936
            }
937
            if (param.pkgPath) {
608!
938
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
939
            }
940
        }
941

942
        //if there's no srcPath, use the destPath to build an absolute srcPath
943
        if (!srcPath) {
3,569✔
944
            srcPath = s`${rootDir}/${destPath}`;
1✔
945
        }
946
        //coerce srcPath to an absolute path
947
        if (!path.isAbsolute(srcPath)) {
3,569✔
948
            srcPath = util.standardizePath(srcPath);
1✔
949
        }
950

951
        //if destPath isn't set, compute it from the other paths
952
        if (!destPath) {
3,569✔
953
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
1✔
954
        }
955

956
        assert.ok(srcPath, 'fileEntry.src is required');
3,569✔
957
        assert.ok(destPath, 'fileEntry.dest is required');
3,569✔
958

959
        return {
3,569✔
960
            srcPath: srcPath,
961
            //remove leading slash
962
            destPath: destPath.replace(/^[\/\\]+/, '')
963
        };
964
    }
965

966
    /**
967
     * Remove any leading `pkg:/` found in the path
968
     */
969
    private removePkgPrefix(path: string) {
970
        return path.replace(/^pkg:\//i, '');
3,568✔
971
    }
972

973
    /**
974
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
975
     */
976
    private isSourceBrsFile(file: BscFile) {
977
        return !!/^(pkg:\/)?source[\/\\]/.exec(file.destPath);
3,570✔
978
    }
979

980
    /**
981
     * Is this file a .brs file found somewhere within the `pkg:/source/` folder?
982
     */
983
    private isComponentsXmlFile(file: BscFile): file is XmlFile {
984
        return isXmlFile(file) && !!/^(pkg:\/)?components[\/\\]/.exec(file.destPath);
3,321✔
985
    }
986

987
    /**
988
     * Ensure source scope is created.
989
     * Note: automatically called internally, and no-op if it exists already.
990
     */
991
    public createSourceScope() {
992
        if (!this.scopes.source) {
3,341✔
993
            const sourceScope = new Scope('source', this, 'scope:source');
2,149✔
994
            sourceScope.attachDependencyGraph(this.dependencyGraph);
2,149✔
995
            this.addScope(sourceScope);
2,149✔
996
            this.plugins.emit('afterProvideScope', {
2,149✔
997
                program: this,
998
                scope: sourceScope
999
            });
1000
        }
1001
    }
1002

1003
    /**
1004
     * Remove a set of files from the program
1005
     * @param srcPaths can be an array of srcPath or destPath strings
1006
     * @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
1007
     */
1008
    public removeFiles(srcPaths: string[], normalizePath = true) {
1✔
1009
        for (let srcPath of srcPaths) {
1✔
1010
            this.removeFile(srcPath, normalizePath);
1✔
1011
        }
1012
    }
1013

1014
    /**
1015
     * Remove a file from the program
1016
     * @param filePath can be a srcPath, a destPath, or a destPath with leading `pkg:/`
1017
     * @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
1018
     */
1019
    public removeFile(filePath: string, normalizePath = true, keepSymbolInformation = false) {
40✔
1020
        this.logger.debug('Program.removeFile()', filePath);
247✔
1021
        const paths = this.getPaths(filePath, this.options.rootDir);
247✔
1022

1023
        //namespace contributions may have included this file; force the program-level
1024
        //contributors map to rebuild on next query
1025
        this.invalidateNamespaceContributorCache();
247✔
1026

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

1030
        for (const file of files) {
247✔
1031
            //if a file has already been removed, nothing more needs to be done here
1032
            if (!file || !this.hasFile(file.srcPath)) {
250✔
1033
                continue;
1✔
1034
            }
1035
            this.diagnostics.clearForFile(file.srcPath);
249✔
1036

1037
            const event: BeforeRemoveFileEvent = { file: file, program: this };
249✔
1038
            this.plugins.emit('beforeRemoveFile', event);
249✔
1039

1040
            //if there is a scope named the same as this file's path, remove it (i.e. xml scopes)
1041
            let scope = this.scopes[file.destPath];
249✔
1042
            if (scope) {
249✔
1043
                this.logger.debug('Removing associated scope', scope.name);
16✔
1044
                const scopeRemoveEvent = {
16✔
1045
                    program: this,
1046
                    scope: scope
1047
                };
1048
                this.plugins.emit('beforeRemoveScope', scopeRemoveEvent);
16✔
1049
                this.plugins.emit('removeScope', scopeRemoveEvent);
16✔
1050
                scope.dispose();
16✔
1051
                //notify dependencies of this scope that it has been removed
1052
                this.dependencyGraph.remove(scope.dependencyGraphKey!);
16✔
1053
                this.removeScope(this.scopes[file.destPath]);
16✔
1054
                this.plugins.emit('afterRemoveScope', scopeRemoveEvent);
16✔
1055
            }
1056
            //remove the file from the program
1057
            this.unassignFile(file);
249✔
1058

1059
            this.dependencyGraph.remove(file.dependencyGraphKey);
249✔
1060

1061
            //if this is a pkg:/source file, notify the `source` scope that it has changed
1062
            if (this.isSourceBrsFile(file)) {
249✔
1063
                this.dependencyGraph.removeDependency('scope:source', file.dependencyGraphKey);
177✔
1064
            }
1065
            if (isBrsFile(file)) {
249✔
1066
                this.logger.debug('Removing file symbol info', file.srcPath);
225✔
1067

1068
                if (!keepSymbolInformation) {
225✔
1069
                    this.fileSymbolInformation.delete(file.pkgPath);
15✔
1070
                }
1071
                this.crossScopeValidation.clearResolutionsForFile(file);
225✔
1072
            }
1073

1074
            this.diagnostics.clearForFile(file.srcPath);
249✔
1075

1076
            //if this is a component, remove it from our components map
1077
            if (isXmlFile(file)) {
249✔
1078
                this.logger.debug('Unregistering component', file.srcPath);
16✔
1079

1080
                this.unregisterComponent(file);
16✔
1081
            }
1082
            this.logger.debug('Disposing file', file.srcPath);
249✔
1083

1084
            //dispose any disposable things on the file
1085
            for (const disposable of file?.disposables ?? []) {
249!
1086
                disposable();
241✔
1087
            }
1088
            //dispose file
1089
            file?.dispose?.();
249!
1090

1091
            this.plugins.emit('afterRemoveFile', event);
249✔
1092
        }
1093
    }
1094

1095
    public crossScopeValidation = new CrossScopeValidator(this);
2,506✔
1096

1097
    private isFirstValidation = true;
2,506✔
1098

1099
    private validationDetails: {
2,506✔
1100
        brsFilesValidated: BrsFile[];
1101
        xmlFilesValidated: XmlFile[];
1102
        changedSymbols: Map<SymbolTypeFlag, Set<string>>;
1103
        changedComponentTypes: string[];
1104
        scopesToValidate: Scope[];
1105
        filesToBeValidatedInScopeContext: Set<BscFile>;
1106

1107
    } = {
1108
            brsFilesValidated: [],
1109
            xmlFilesValidated: [],
1110
            changedSymbols: new Map<SymbolTypeFlag, Set<string>>(),
1111
            changedComponentTypes: [],
1112
            scopesToValidate: [],
1113
            filesToBeValidatedInScopeContext: new Set<BscFile>()
1114
        };
1115

1116
    public lastValidationInfo: {
2,506✔
1117
        brsFilesSrcPath: Set<string>;
1118
        xmlFilesSrcPath: Set<string>;
1119
        scopeNames: Set<string>;
1120
        componentsRebuilt: Set<string>;
1121
    } = {
1122
            brsFilesSrcPath: new Set<string>(),
1123
            xmlFilesSrcPath: new Set<string>(),
1124
            scopeNames: new Set<string>(),
1125
            componentsRebuilt: new Set<string>()
1126
        };
1127

1128
    /**
1129
     * Counter used to track which validation run is being logged
1130
     */
1131
    private validationRunSequence = 1;
2,506✔
1132

1133
    /**
1134
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
1135
     */
1136
    private validationMinSyncDuration = 75;
2,506✔
1137

1138
    private validatePromise: Promise<void> | undefined;
1139

1140
    /**
1141
     * Traverse the entire project, and validate all scopes
1142
     */
1143
    public validate(): void;
1144
    public validate(options: { async: false; cancellationToken?: CancellationToken }): void;
1145
    public validate(options: { async: true; cancellationToken?: CancellationToken }): Promise<void>;
1146
    public validate(options?: { async?: boolean; cancellationToken?: CancellationToken }) {
1147
        const validationRunId = this.validationRunSequence++;
2,178✔
1148

1149
        let previousValidationPromise = this.validatePromise;
2,178✔
1150
        const deferred = new Deferred();
2,178✔
1151

1152
        if (options?.async) {
2,178✔
1153
            //we're async, so create a new promise chain to resolve after this validation is done
1154
            this.validatePromise = Promise.resolve(previousValidationPromise).then(() => {
368✔
1155
                return deferred.promise;
368✔
1156
            });
1157

1158
            //we are not async but there's a pending promise, then we cannot run this validation
1159
        } else if (previousValidationPromise !== undefined) {
1,810!
1160
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
1161
        }
1162

1163
        let beforeValidateProgramWasEmitted = false;
2,178✔
1164

1165
        const brsFilesValidated: BrsFile[] = this.validationDetails.brsFilesValidated;
2,178✔
1166
        const xmlFilesValidated: XmlFile[] = this.validationDetails.xmlFilesValidated;
2,178✔
1167
        const changedSymbols = this.validationDetails.changedSymbols;
2,178✔
1168
        const changedComponentTypes = this.validationDetails.changedComponentTypes;
2,178✔
1169
        const scopesToValidate = this.validationDetails.scopesToValidate;
2,178✔
1170
        const filesToBeValidatedInScopeContext = this.validationDetails.filesToBeValidatedInScopeContext;
2,178✔
1171

1172
        //validate every file
1173

1174
        let logValidateEnd = (status?: string) => { };
2,178✔
1175

1176
        //will be populated later on during the correspnding sequencer event
1177
        let filesToProcess: BscFile[];
1178

1179
        const sequencer = new Sequencer({
2,178✔
1180
            name: 'program.validate',
1181
            cancellationToken: options?.cancellationToken ?? new CancellationTokenSource().token,
13,068✔
1182
            minSyncDuration: this.validationMinSyncDuration
1183
        });
1184
        //this sequencer allows us to run in both sync and async mode, depending on whether options.async is enabled.
1185
        //We use this to prevent starving the CPU during long validate cycles when running in a language server context
1186
        sequencer
2,178✔
1187
            .once('wait for previous run', () => {
1188
                //if running in async mode, return the previous validation promise to ensure we're only running one at a time
1189
                if (options?.async) {
2,178✔
1190
                    return previousValidationPromise;
368✔
1191
                }
1192
            })
1193
            .once('before and on programValidate', () => {
1194
                logValidateEnd = this.logger.timeStart(LogLevel.log, `Validating project${(this.logger.logLevel as LogLevel) > LogLevel.log ? ` (run ${validationRunId})` : ''}`);
2,164!
1195
                this.diagnostics.clearForTag(ProgramValidatorDiagnosticsTag);
2,164✔
1196
                this.plugins.emit('beforeValidateProgram', {
2,164✔
1197
                    program: this
1198
                });
1199
                beforeValidateProgramWasEmitted = true;
2,164✔
1200
                this.plugins.emit('validateProgram', {
2,164✔
1201
                    program: this
1202
                });
1203
            })
1204
            .once('get files to be validated', () => {
1205
                filesToProcess = Object.values(this.files).sort(firstBy(x => x.srcPath)).filter(x => !x.isValidated);
5,080✔
1206
                for (const file of filesToProcess) {
2,164✔
1207
                    filesToBeValidatedInScopeContext.add(file);
2,813✔
1208
                }
1209
            })
1210
            .once('add component reference types', () => {
1211
                // Create reference component types for any component that changes
1212
                for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
2,161✔
1213
                    this.addComponentReferenceType(componentKey, componentName);
473✔
1214
                }
1215
            })
1216
            //run before/validate/after as a single per-file action so the Sequencer can't cancel
1217
            //between them. Splitting into three forEach steps would let cancellation land after
1218
            //`validateFile` (where BrsFileValidator pushes per-node symbols via addSymbol) but
1219
            //before `afterValidateFile` (where processSymbolInformation registers validation
1220
            //segments) — leaving the file in a half-processed state. The next pass would either
1221
            //re-run BrsFileValidator (pushing duplicate symbols → CrossScopeValidator name-
1222
            //collision) or skip the file in scope validation (no segments to walk → no
1223
            //diagnostics emitted). Atomic-per-file means either the file is fully processed or
1224
            //it's untouched. Plugins that need an all-files-done signal should use
1225
            //`afterValidateProgram` instead of `afterValidateFile`.
1226
            .forEach('validateFile', () => filesToProcess, (file) => {
2,160✔
1227
                this.plugins.emit('beforeValidateFile', {
2,806✔
1228
                    program: this,
1229
                    file: file
1230
                });
1231
                this.plugins.emit('validateFile', {
2,806✔
1232
                    program: this,
1233
                    file: file
1234
                });
1235
                this.plugins.emit('afterValidateFile', {
2,806✔
1236
                    program: this,
1237
                    file: file
1238
                });
1239
                file.isValidated = true;
2,806✔
1240
                if (isBrsFile(file)) {
2,806✔
1241
                    brsFilesValidated.push(file);
2,331✔
1242
                } else if (isXmlFile(file)) {
475!
1243
                    xmlFilesValidated.push(file);
475✔
1244
                }
1245
            })
1246
            .forEach('do deferred component creation', () => [...brsFilesValidated, ...xmlFilesValidated], (file) => {
2,149✔
1247
                if (isXmlFile(file)) {
2,817✔
1248
                    this.addDeferredComponentTypeSymbolCreation(file);
475✔
1249
                } else if (isBrsFile(file)) {
2,342!
1250
                    const fileHasChanges = file.providedSymbols.changes.get(SymbolTypeFlag.runtime).size > 0 || file.providedSymbols.changes.get(SymbolTypeFlag.typetime).size > 0;
2,342✔
1251
                    if (fileHasChanges) {
2,342✔
1252
                        for (const scope of this.getScopesForFile(file)) {
2,139✔
1253
                            if (isXmlScope(scope) && this.doesXmlFileRequireProvidedSymbols(scope.xmlFile, file.providedSymbols.changes)) {
2,489✔
1254
                                this.addDeferredComponentTypeSymbolCreation(scope.xmlFile);
71✔
1255
                            }
1256
                        }
1257
                    }
1258
                }
1259
            })
1260
            .once('build component types for any component that changes', () => {
1261
                this.logger.time(LogLevel.info, ['Build component types'], () => {
2,142✔
1262
                    this.logger.debug(`Component Symbols to update:`, [...this.componentSymbolsToUpdate.entries()].sort());
2,142✔
1263
                    this.lastValidationInfo.componentsRebuilt = new Set<string>();
2,142✔
1264
                    for (let [componentKey, componentName] of this.componentSymbolsToUpdate.entries()) {
2,142✔
1265
                        this.lastValidationInfo.componentsRebuilt.add(componentName?.toLowerCase());
477✔
1266
                        if (this.updateComponentSymbolInGlobalScope(componentKey, componentName)) {
477✔
1267
                            changedComponentTypes.push(util.getSgNodeTypeName(componentName).toLowerCase());
467✔
1268
                        }
1269
                    }
1270
                    this.componentSymbolsToUpdate.clear();
2,142✔
1271
                });
1272
            })
1273
            .once('track and update type-time and runtime symbol dependencies and changes', () => {
1274
                const changedSymbolsMapArr = [...brsFilesValidated, ...xmlFilesValidated]?.map(f => {
2,140!
1275
                    if (isBrsFile(f)) {
2,806✔
1276
                        return f.providedSymbols.changes;
2,331✔
1277
                    }
1278
                    return null;
475✔
1279
                }).filter(x => x);
2,806✔
1280

1281
                // update the map of typetime dependencies
1282
                for (const file of brsFilesValidated) {
2,140✔
1283
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
2,331✔
1284
                        // clear existing dependencies
1285
                        for (const values of this.symbolDependencies.values()) {
836✔
1286
                            values.delete(symbolName);
62✔
1287
                        }
1288

1289
                        // map types to the set of types that depend upon them
1290
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
836!
1291
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
209✔
1292
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
209✔
1293
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
187✔
1294
                            }
1295
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
209✔
1296
                            symbolsDependentUpon.add(symbolName);
209✔
1297
                        }
1298
                    }
1299
                }
1300

1301
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
2,140✔
1302
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
4,662✔
1303
                    const changedSymbolSet = new Set<string>();
4,280✔
1304
                    for (const changeSet of changedSymbolsSetArr) {
4,280✔
1305
                        for (const change of changeSet) {
4,662✔
1306
                            changedSymbolSet.add(change);
4,395✔
1307
                        }
1308
                    }
1309
                    if (!changedSymbols.has(flag)) {
4,280✔
1310
                        changedSymbols.set(flag, changedSymbolSet);
4,130✔
1311
                    } else {
1312
                        changedSymbols.set(flag, new Set([...changedSymbols.get(flag), ...changedSymbolSet]));
150✔
1313
                    }
1314
                }
1315

1316
                // update changed symbol set with any changed component
1317
                for (const changedComponentType of changedComponentTypes) {
2,140✔
1318
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
467✔
1319
                }
1320

1321
                // Add any additional types that depend on a changed type
1322
                // as each iteration of the loop might add new types, need to keep checking until nothing new is added
1323
                const dependentTypesChanged = new Set<string>();
2,140✔
1324
                let foundDependentTypes = false;
2,140✔
1325
                const changedTypeSymbols = changedSymbols.get(SymbolTypeFlag.typetime);
2,140✔
1326
                do {
2,140✔
1327
                    foundDependentTypes = false;
2,146✔
1328
                    const allChangedTypesSofar = [...Array.from(changedTypeSymbols), ...Array.from(dependentTypesChanged)];
2,146✔
1329
                    for (const changedSymbol of allChangedTypesSofar) {
2,146✔
1330
                        const symbolsDependentUponChangedSymbol = this.symbolDependencies.get(changedSymbol) ?? [];
1,305✔
1331
                        for (const symbolName of symbolsDependentUponChangedSymbol) {
1,305✔
1332
                            if (!changedTypeSymbols.has(symbolName) && !dependentTypesChanged.has(symbolName)) {
207✔
1333
                                foundDependentTypes = true;
6✔
1334
                                dependentTypesChanged.add(symbolName);
6✔
1335
                            }
1336
                        }
1337
                    }
1338
                } while (foundDependentTypes);
1339

1340
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedSymbols.get(SymbolTypeFlag.typetime), ...changedTypeSymbols, ...dependentTypesChanged]));
2,140✔
1341

1342
                this.lastValidationInfo.brsFilesSrcPath = new Set<string>(this.validationDetails.brsFilesValidated.map(f => f.srcPath?.toLowerCase() ?? ''));
2,329!
1343
                this.lastValidationInfo.xmlFilesSrcPath = new Set<string>(this.validationDetails.xmlFilesValidated.map(f => f.srcPath?.toLowerCase() ?? ''));
2,140!
1344

1345
                // can reset filesValidatedList, because they are no longer needed
1346
                this.validationDetails.brsFilesValidated = [];
2,140✔
1347
                this.validationDetails.xmlFilesValidated = [];
2,140✔
1348
            })
1349
            .once('tracks changed symbols and prepares files and scopes for validation', () => {
1350
                if (this.options.logLevel === LogLevel.debug) {
2,120!
1351
                    const changedRuntime = Array.from(changedSymbols.get(SymbolTypeFlag.runtime)).sort();
×
1352
                    this.logger.debug('Changed Symbols (runTime):', changedRuntime.join(', '));
×
1353
                    const changedTypetime = Array.from(changedSymbols.get(SymbolTypeFlag.typetime)).sort();
×
1354
                    this.logger.debug('Changed Symbols (typeTime):', changedTypetime.join(', '));
×
1355
                }
1356
                const didComponentChange = changedComponentTypes.length > 0;
2,120✔
1357
                const didProvidedSymbolChange = changedSymbols.get(SymbolTypeFlag.runtime).size > 0 || changedSymbols.get(SymbolTypeFlag.typetime).size > 0;
2,120✔
1358
                const scopesToCheck = this.getScopesForCrossScopeValidation(didComponentChange, didProvidedSymbolChange);
2,120✔
1359

1360
                this.crossScopeValidation.buildComponentsMap();
2,120✔
1361
                this.logger.time(LogLevel.info, ['addDiagnosticsForScopes'], () => {
2,120✔
1362
                    this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
2,120✔
1363
                });
1364
                const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
2,120✔
1365
                for (const file of filesToRevalidate) {
2,120✔
1366
                    filesToBeValidatedInScopeContext.add(file);
518✔
1367
                }
1368

1369
                this.currentScopeValidationOptions = {
2,120✔
1370
                    filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1371
                    changedSymbols: changedSymbols,
1372
                    changedFiles: Array.from(filesToBeValidatedInScopeContext),
1373
                    initialValidation: this.isFirstValidation
1374
                };
1375

1376
                //can reset changedComponent types
1377
                this.validationDetails.changedComponentTypes = [];
2,120✔
1378
            })
1379
            .forEach('invalidate affected scopes', () => filesToBeValidatedInScopeContext, (file) => {
2,120✔
1380
                if (isBrsFile(file)) {
2,964✔
1381
                    file.validationSegmenter.unValidateAllSegments();
2,489✔
1382
                    for (const scope of this.getScopesForFile(file)) {
2,489✔
1383
                        scope.invalidate();
2,874✔
1384
                    }
1385
                }
1386
            })
1387
            .once('checking scopes to validate', () => {
1388
                //sort the scope names so we get consistent results
1389
                for (const scopeName of this.getSortedScopeNames()) {
2,064✔
1390
                    let scope = this.scopes[scopeName];
4,573✔
1391
                    if (scope.shouldValidate(this.currentScopeValidationOptions)) {
4,573✔
1392
                        scopesToValidate.push(scope);
2,430✔
1393
                    }
1394
                }
1395
                this.lastValidationInfo.scopeNames = new Set<string>(scopesToValidate.map(s => s.name?.toLowerCase() ?? ''));
2,438!
1396
            })
1397
            .forEach('beforeScopeValidate', () => scopesToValidate, (scope) => {
2,064✔
1398
                this.plugins.emit('beforeValidateScope', {
2,438✔
1399
                    program: this,
1400
                    scope: scope
1401
                });
1402
            })
1403
            .forEach('validate scope', () => scopesToValidate, (scope) => {
2,063✔
1404
                scope.validate(this.currentScopeValidationOptions);
2,434✔
1405
            })
1406
            .forEach('afterValidateScope', () => scopesToValidate, (scope) => {
2,058✔
1407
                this.plugins.emit('afterValidateScope', {
2,426✔
1408
                    program: this,
1409
                    scope: scope
1410
                });
1411
            })
1412
            .once('detect duplicate component names', () => {
1413
                this.detectDuplicateComponentNames();
2,057✔
1414
                this.isFirstValidation = false;
2,057✔
1415

1416
                // can reset other validation details
1417
                this.validationDetails.changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
2,057✔
1418
                this.validationDetails.scopesToValidate = [];
2,057✔
1419
                this.validationDetails.filesToBeValidatedInScopeContext = new Set<BscFile>();
2,057✔
1420

1421
            })
1422
            .onCancel(() => {
1423
                logValidateEnd('cancelled');
121✔
1424
            })
1425
            .onSuccess(() => {
1426
                logValidateEnd();
2,057✔
1427
            })
1428
            .onComplete(() => {
1429
                //if we emitted the beforeValidateProgram hook, emit the afterValidateProgram hook as well
1430
                if (beforeValidateProgramWasEmitted) {
2,178✔
1431
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
2,164✔
1432
                    this.plugins.emit('afterValidateProgram', {
2,164✔
1433
                        program: this,
1434
                        wasCancelled: wasCancelled
1435
                    });
1436
                }
1437

1438
                //log all the sequencer timing metrics if `info` logging is enabled
1439
                this.logger.info(
2,178✔
1440
                    sequencer.formatMetrics({
1441
                        header: 'Program.validate metrics:',
1442
                        //only include loop iterations if `debug` logging is enabled
1443
                        includeLoopIterations: this.logger.isLogLevelEnabled(LogLevel.debug)
1444
                    })
1445
                );
1446

1447
                //regardless of the success of the validation, mark this run as complete
1448
                deferred.resolve();
2,178✔
1449
                //clear the validatePromise which means we're no longer running a validation
1450
                this.validatePromise = undefined;
2,178✔
1451
            });
1452

1453
        //run the sequencer in async mode if enabled
1454
        if (options?.async) {
2,178✔
1455
            return sequencer.run();
368✔
1456

1457
            //run the sequencer in sync mode
1458
        } else {
1459
            return sequencer.runSync();
1,810✔
1460
        }
1461
    }
1462

1463
    private getScopesForCrossScopeValidation(someComponentTypeChanged: boolean, didProvidedSymbolChange: boolean) {
1464
        const scopesForCrossScopeValidation: Scope[] = [];
2,120✔
1465
        for (let scopeName of this.getSortedScopeNames()) {
2,120✔
1466
            let scope = this.scopes[scopeName];
4,629✔
1467
            if (this.globalScope === scope) {
4,629✔
1468
                continue;
2,120✔
1469
            }
1470
            if (someComponentTypeChanged) {
2,509✔
1471
                scopesForCrossScopeValidation.push(scope);
663✔
1472
            }
1473
            if (didProvidedSymbolChange && !scope.isValidated) {
2,509✔
1474
                scopesForCrossScopeValidation.push(scope);
2,295✔
1475
            }
1476
        }
1477
        return scopesForCrossScopeValidation;
2,120✔
1478
    }
1479

1480
    private doesXmlFileRequireProvidedSymbols(file: XmlFile, providedSymbolsByFlag: Map<SymbolTypeFlag, Set<string>>) {
1481
        for (const required of file.requiredSymbols) {
710✔
1482
            const symbolNameLower = (required as UnresolvedXMLSymbol).name.toLowerCase();
79✔
1483
            const requiredSymbolIsProvided = providedSymbolsByFlag.get(required.flags).has(symbolNameLower);
79✔
1484
            if (requiredSymbolIsProvided) {
79✔
1485
                return true;
71✔
1486
            }
1487
        }
1488
        return false;
639✔
1489
    }
1490

1491
    /**
1492
     * Flag all duplicate component names
1493
     */
1494
    private detectDuplicateComponentNames() {
1495
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
2,057✔
1496
            const file = this.files[filePath];
3,285✔
1497
            //if this is an XmlFile, and it has a valid `componentName` property
1498
            if (isXmlFile(file) && file.componentName?.text) {
3,285✔
1499
                let lowerName = file.componentName.text.toLowerCase();
667✔
1500
                if (!map[lowerName]) {
667✔
1501
                    map[lowerName] = [];
664✔
1502
                }
1503
                map[lowerName].push(file);
667✔
1504
            }
1505
            return map;
3,285✔
1506
        }, {});
1507

1508
        for (let name in componentsByName) {
2,057✔
1509
            const xmlFiles = componentsByName[name];
664✔
1510
            //add diagnostics for every duplicate component with this name
1511
            if (xmlFiles.length > 1) {
664✔
1512
                for (let xmlFile of xmlFiles) {
3✔
1513
                    const { componentName } = xmlFile;
6✔
1514
                    this.diagnostics.register({
6✔
1515
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1516
                        location: xmlFile.componentName.location,
1517
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1518
                            return {
6✔
1519
                                location: x.componentName.location,
1520
                                message: 'Also defined here'
1521
                            };
1522
                        })
1523
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1524
                }
1525
            }
1526
        }
1527
    }
1528

1529
    /**
1530
     * Get the files for a list of filePaths
1531
     * @param filePaths can be an array of srcPath or a destPath strings
1532
     * @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
1533
     */
1534
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
33✔
1535
        return filePaths
33✔
1536
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1537
            .filter(file => file !== undefined) as T[];
39✔
1538
    }
1539

1540
    private getFilePathCache = new Map<string, { path: string; isDestMap?: boolean }>();
2,506✔
1541

1542
    /**
1543
     * Get the file at the given path
1544
     * @param filePath can be a srcPath or a destPath
1545
     * @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
1546
     */
1547
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
25,102✔
1548
        if (this.getFilePathCache.has(filePath)) {
33,529✔
1549
            const cachedFilePath = this.getFilePathCache.get(filePath);
19,704✔
1550
            if (cachedFilePath.isDestMap) {
19,704✔
1551
                return this.destMap.get(
16,070✔
1552
                    cachedFilePath.path
1553
                ) as T;
1554
            }
1555
            return this.files[
3,634✔
1556
                cachedFilePath.path
1557
            ] as T;
1558
        }
1559
        if (typeof filePath !== 'string') {
13,825✔
1560
            return undefined;
4,624✔
1561
            //is the path absolute (or the `virtual:` prefix)
1562
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
9,201✔
1563
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
4,376!
1564
            this.getFilePathCache.set(filePath, { path: standardizedPath });
4,376✔
1565

1566
            return this.files[
4,376✔
1567
                standardizedPath
1568
            ] as T;
1569
        } else if (util.isUriLike(filePath)) {
4,825✔
1570
            const path = URI.parse(filePath).fsPath;
704✔
1571
            const standardizedPath = (normalizePath ? util.standardizePath(path) : path).toLowerCase();
704!
1572
            this.getFilePathCache.set(filePath, { path: standardizedPath });
704✔
1573

1574
            return this.files[
704✔
1575
                standardizedPath
1576
            ] as T;
1577
        } else {
1578
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
4,121✔
1579
            this.getFilePathCache.set(filePath, { path: standardizedPath, isDestMap: true });
4,121✔
1580
            return this.destMap.get(
4,121✔
1581
                standardizedPath
1582
            ) as T;
1583
        }
1584
    }
1585

1586
    private sortedScopeNames: string[] = undefined;
2,506✔
1587

1588
    /**
1589
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1590
     */
1591
    private getSortedScopeNames() {
1592
        if (!this.sortedScopeNames) {
14,950✔
1593
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,927✔
1594
                if (a === 'global') {
2,604!
1595
                    return -1;
×
1596
                } else if (b === 'global') {
2,604✔
1597
                    return 1;
1,841✔
1598
                }
1599
                if (a === 'source') {
763✔
1600
                    return -1;
38✔
1601
                } else if (b === 'source') {
725✔
1602
                    return 1;
198✔
1603
                }
1604
                if (a < b) {
527✔
1605
                    return -1;
214✔
1606
                } else if (b < a) {
313!
1607
                    return 1;
313✔
1608
                }
1609
                return 0;
×
1610
            });
1611
        }
1612
        return this.sortedScopeNames;
14,950✔
1613
    }
1614

1615
    /**
1616
     * Get a list of all scopes the file is loaded into
1617
     * @param file the file
1618
     */
1619
    public getScopesForFile(file: BscFile | string) {
1620
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
5,329✔
1621

1622
        let result = [] as Scope[];
5,329✔
1623
        if (resolvedFile) {
5,329✔
1624
            const scopeKeys = this.getSortedScopeNames();
5,328✔
1625
            for (let key of scopeKeys) {
5,328✔
1626
                let scope = this.scopes[key];
42,467✔
1627

1628
                if (scope.hasFile(resolvedFile)) {
42,467✔
1629
                    result.push(scope);
6,079✔
1630
                }
1631
            }
1632
        }
1633
        return result;
5,329✔
1634
    }
1635

1636
    /**
1637
     * Get the first found scope for a file.
1638
     */
1639
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1640
        const scopeKeys = this.getSortedScopeNames();
5,438✔
1641
        for (let key of scopeKeys) {
5,438✔
1642
            let scope = this.scopes[key];
21,443✔
1643

1644
            if (scope.hasFile(file)) {
21,443✔
1645
                return scope;
4,041✔
1646
            }
1647
        }
1648
    }
1649

1650
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1651
        let results = new Map<Statement, FileLink<Statement>>();
39✔
1652
        const filesSearched = new Set<BrsFile>();
39✔
1653
        let lowerNamespaceName = namespaceName?.toLowerCase();
39✔
1654
        let lowerName = name?.toLowerCase();
39!
1655

1656
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1657
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
98✔
1658
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
98✔
1659
                if (!results.has(statement)) {
36!
1660
                    results.set(statement, { item: statement, file: file as BrsFile });
36✔
1661
                }
1662
            }
1663
        }
1664

1665
        //look through all files in scope for matches
1666
        for (const scope of this.getScopesForFile(originFile)) {
39✔
1667
            for (const file of scope.getAllFiles()) {
39✔
1668
                //skip non-brs files, or files we've already processed
1669
                if (!isBrsFile(file) || filesSearched.has(file)) {
45✔
1670
                    continue;
3✔
1671
                }
1672
                filesSearched.add(file);
42✔
1673

1674
                file.ast.walk(createVisitor({
42✔
1675
                    FunctionStatement: (statement: FunctionStatement) => {
1676
                        addToResults(statement, file);
95✔
1677
                    },
1678
                    MethodStatement: (statement: MethodStatement) => {
1679
                        addToResults(statement, file);
3✔
1680
                    }
1681
                }), {
1682
                    walkMode: WalkMode.visitStatements
1683
                });
1684
            }
1685
        }
1686
        return [...results.values()];
39✔
1687
    }
1688

1689
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1690
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1691
        const filesSearched = new Set<BrsFile>();
14✔
1692

1693
        //get all function names for the xml file and parents
1694
        let funcNames = new Set<string>();
14✔
1695
        let currentScope = scope;
14✔
1696
        while (isXmlScope(currentScope)) {
14✔
1697
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1698
                if (!filterName || name === filterName) {
20!
1699
                    funcNames.add(name);
20✔
1700
                }
1701
            }
1702
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1703
        }
1704

1705
        //look through all files in scope for matches
1706
        for (const file of scope.getOwnFiles()) {
14✔
1707
            //skip non-brs files, or files we've already processed
1708
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1709
                continue;
14✔
1710
            }
1711
            filesSearched.add(file);
14✔
1712

1713
            file.ast.walk(createVisitor({
14✔
1714
                FunctionStatement: (statement: FunctionStatement) => {
1715
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1716
                        if (!results.has(statement)) {
19!
1717
                            results.set(statement, { item: statement, file: file });
19✔
1718
                        }
1719
                    }
1720
                }
1721
            }), {
1722
                walkMode: WalkMode.visitStatements
1723
            });
1724
        }
1725
        return [...results.values()];
14✔
1726
    }
1727

1728
    /**
1729
     * Find all available completion items at the given position
1730
     * @param filePath can be a srcPath or a destPath
1731
     * @param position the position (line & column) where completions should be found
1732
     */
1733
    public getCompletions(filePath: string, position: Position) {
1734
        let file = this.getFile(filePath);
131✔
1735
        if (!file) {
131!
1736
            return [];
×
1737
        }
1738

1739
        const event: ProvideCompletionsEvent = {
131✔
1740
            program: this,
1741
            file: file,
1742
            scopes: this.getScopesForFile(file),
1743
            position: position,
1744
            completions: []
1745
        };
1746

1747
        this.plugins.emit('beforeProvideCompletions', event);
131✔
1748

1749
        this.plugins.emit('provideCompletions', event);
131✔
1750

1751
        this.plugins.emit('afterProvideCompletions', event);
131✔
1752

1753
        return event.completions;
131✔
1754
    }
1755

1756
    /**
1757
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1758
     */
1759
    public getWorkspaceSymbols() {
1760
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1761
            program: this,
1762
            workspaceSymbols: []
1763
        };
1764
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1765
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1766
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1767
        return event.workspaceSymbols;
22✔
1768
    }
1769

1770
    /**
1771
     * Given a position in a file, if the position is sitting on some type of identifier,
1772
     * go to the definition of that identifier (where this thing was first defined)
1773
     */
1774
    public getDefinition(srcPath: string, position: Position): Location[] {
1775
        let file = this.getFile(srcPath);
24✔
1776
        if (!file) {
24!
1777
            return [];
×
1778
        }
1779

1780
        const event: ProvideDefinitionEvent = {
24✔
1781
            program: this,
1782
            file: file,
1783
            position: position,
1784
            definitions: []
1785
        };
1786

1787
        this.plugins.emit('beforeProvideDefinition', event);
24✔
1788
        this.plugins.emit('provideDefinition', event);
24✔
1789
        this.plugins.emit('afterProvideDefinition', event);
24✔
1790
        return event.definitions;
24✔
1791
    }
1792

1793
    /**
1794
     * Get hover information for a file and position
1795
     */
1796
    public getHover(srcPath: string, position: Position): Hover[] {
1797
        let file = this.getFile(srcPath);
97✔
1798
        let result: Hover[];
1799
        if (file) {
97!
1800
            const event = {
97✔
1801
                program: this,
1802
                file: file,
1803
                position: position,
1804
                scopes: this.getScopesForFile(file),
1805
                hovers: []
1806
            } as ProvideHoverEvent;
1807
            this.plugins.emit('beforeProvideHover', event);
97✔
1808
            this.plugins.emit('provideHover', event);
97✔
1809
            this.plugins.emit('afterProvideHover', event);
97✔
1810
            result = event.hovers;
97✔
1811
        }
1812

1813
        return result ?? [];
97!
1814
    }
1815

1816
    /**
1817
     * Get full list of document symbols for a file
1818
     * @param srcPath path to the file
1819
     */
1820
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1821
        let file = this.getFile(srcPath);
24✔
1822
        if (file) {
24!
1823
            const event: ProvideDocumentSymbolsEvent = {
24✔
1824
                program: this,
1825
                file: file,
1826
                documentSymbols: []
1827
            };
1828
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1829
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1830
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1831
            return event.documentSymbols;
24✔
1832
        } else {
1833
            return undefined;
×
1834
        }
1835
    }
1836

1837
    /**
1838
     * Get the selection ranges for the given positions in a file. Used for expand/shrink selection.
1839
     * @param srcPath path to the file
1840
     * @param positions the positions to get selection ranges for
1841
     */
1842
    public getSelectionRanges(srcPath: string, positions: Position[]): SelectionRange[] {
1843
        const file = this.getFile(srcPath);
15✔
1844
        if (file) {
15✔
1845
            const event: ProvideSelectionRangesEvent = {
14✔
1846
                program: this,
1847
                file: file,
1848
                positions: positions,
1849
                selectionRanges: []
1850
            };
1851
            this.plugins.emit('beforeProvideSelectionRanges', event);
14✔
1852
            this.plugins.emit('provideSelectionRanges', event);
14✔
1853
            this.plugins.emit('afterProvideSelectionRanges', event);
14✔
1854
            return event.selectionRanges;
14✔
1855
        }
1856
        return [];
1✔
1857
    }
1858

1859
    /**
1860
     * Compute code actions for the given file and range
1861
     */
1862
    public getCodeActions(srcPath: string, range: Range) {
1863
        const codeActions = [] as CodeAction[];
53✔
1864
        const file = this.getFile(srcPath);
53✔
1865
        if (file) {
53✔
1866
            const fileUri = util.pathToUri(file?.srcPath);
52!
1867
            const diagnostics = this
52✔
1868
                //get all current diagnostics (filtered by diagnostic filters)
1869
                .getDiagnostics()
1870
                //only keep diagnostics related to this file
1871
                .filter(x => x.location?.uri === fileUri)
108!
1872
                //only keep diagnostics that touch this range
1873
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
90✔
1874

1875
            const scopes = this.getScopesForFile(file);
52✔
1876

1877
            this.plugins.emit('beforeProvideCodeActions', {
52✔
1878
                program: this,
1879
                file: file,
1880
                range: range,
1881
                diagnostics: diagnostics,
1882
                scopes: scopes,
1883
                codeActions: codeActions
1884
            });
1885

1886
            this.plugins.emit('provideCodeActions', {
52✔
1887
                program: this,
1888
                file: file,
1889
                range: range,
1890
                diagnostics: diagnostics,
1891
                scopes: scopes,
1892
                codeActions: codeActions
1893
            });
1894

1895
            this.plugins.emit('afterProvideCodeActions', {
52✔
1896
                program: this,
1897
                file: file,
1898
                range: range,
1899
                diagnostics: diagnostics,
1900
                scopes: scopes,
1901
                codeActions: codeActions
1902
            });
1903
        }
1904
        return codeActions;
53✔
1905
    }
1906

1907
    /**
1908
     * Compute "source fix all" code actions for the given file.
1909
     * Fires the `onGetSourceFixAllCodeActions` plugin event with all diagnostics for the file (no range filter),
1910
     * then converts each contributed SourceFixAllCodeAction into an LSP CodeAction.
1911
     */
1912
    public getSourceFixAllCodeActions(srcPath: string): CodeAction[] {
1913
        const actions: SourceFixAllCodeAction[] = [];
×
1914
        const file = this.getFile(srcPath);
×
1915
        if (file) {
×
1916
            const fileUri = util.pathToUri(file.srcPath);
×
1917
            const diagnostics = this
×
1918
                .getDiagnostics()
1919
                .filter(x => x.location?.uri === fileUri);
×
1920
            const scopes = this.getScopesForFile(file);
×
1921
            this.plugins.emit('onGetSourceFixAllCodeActions', {
×
1922
                program: this,
1923
                file: file,
1924
                diagnostics: diagnostics,
1925
                scopes: scopes,
1926
                actions: actions
1927
            } as OnGetSourceFixAllCodeActionsEvent);
1928
        }
1929
        return actions.map(action => codeActionUtil.createCodeAction({
×
1930
            ...action,
1931
            kind: action.kind ?? 'source.fixAll.brighterscript' as any
×
1932
        }));
1933
    }
1934

1935
    /**
1936
     * Get semantic tokens for the specified file
1937
     */
1938
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1939
        const file = this.getFile(srcPath);
26✔
1940
        if (file) {
26!
1941
            this.plugins.emit('beforeProvideSemanticTokens', {
26✔
1942
                program: this,
1943
                file: file,
1944
                scopes: this.getScopesForFile(file),
1945
                semanticTokens: undefined
1946
            });
1947
            const result = [] as SemanticToken[];
26✔
1948
            this.plugins.emit('provideSemanticTokens', {
26✔
1949
                program: this,
1950
                file: file,
1951
                scopes: this.getScopesForFile(file),
1952
                semanticTokens: result
1953
            });
1954
            this.plugins.emit('afterProvideSemanticTokens', {
26✔
1955
                program: this,
1956
                file: file,
1957
                scopes: this.getScopesForFile(file),
1958
                semanticTokens: result
1959
            });
1960
            return result;
26✔
1961
        }
1962
    }
1963

1964
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1965
        let file: BrsFile = this.getFile(filePath);
188✔
1966
        if (!file || !isBrsFile(file)) {
188✔
1967
            return [];
3✔
1968
        }
1969
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1970
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1971
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1972
    }
1973

1974
    public getReferences(srcPath: string, position: Position): Location[] {
1975
        //find the file
1976
        let file = this.getFile(srcPath);
4✔
1977

1978
        const event: ProvideReferencesEvent = {
4✔
1979
            program: this,
1980
            file: file,
1981
            position: position,
1982
            references: []
1983
        };
1984

1985
        this.plugins.emit('beforeProvideReferences', event);
4✔
1986
        this.plugins.emit('provideReferences', event);
4✔
1987
        this.plugins.emit('afterProvideReferences', event);
4✔
1988

1989
        return event.references;
4✔
1990
    }
1991

1992
    /**
1993
     * Transpile a single file and get the result as a string.
1994
     * This does not write anything to the file system.
1995
     *
1996
     * This should only be called by `LanguageServer`.
1997
     * Internal usage should call `_getTranspiledFileContents` instead.
1998
     * @param filePath can be a srcPath or a destPath
1999
     */
2000
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
2001
        const file = this.getFile(filePath);
384✔
2002

2003
        return this.getTranspiledFileContentsPipeline.run(async () => {
384✔
2004

2005
            const result = {
384✔
2006
                destPath: file.destPath,
2007
                pkgPath: file.pkgPath,
2008
                srcPath: file.srcPath
2009
            } as FileTranspileResult;
2010

2011
            const expectedPkgPath = file.pkgPath.toLowerCase();
384✔
2012
            const expectedMapPath = `${expectedPkgPath}.map`;
384✔
2013
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
384✔
2014

2015
            //add a temporary plugin to tap into the file writing process
2016
            const plugin = this.plugins.addFirst({
384✔
2017
                name: 'getTranspiledFileContents',
2018
                beforeWriteFile: (event) => {
2019
                    const pkgPath = event.file.pkgPath.toLowerCase();
1,244✔
2020
                    switch (pkgPath) {
1,244✔
2021
                        //this is the actual transpiled file
2022
                        case expectedPkgPath:
1,244✔
2023
                            result.code = event.file.data.toString();
384✔
2024
                            break;
384✔
2025
                        //this is the sourcemap
2026
                        case expectedMapPath:
2027
                            result.map = event.file.data.toString();
228✔
2028
                            break;
228✔
2029
                        //this is the typedef
2030
                        case expectedTypedefPkgPath:
2031
                            result.typedef = event.file.data.toString();
10✔
2032
                            break;
10✔
2033
                        default:
2034
                        //no idea what this file is. just ignore it
2035
                    }
2036
                    //mark every file as processed so it they don't get written to the output directory
2037
                    event.processedFiles.add(event.file);
1,244✔
2038
                }
2039
            });
2040

2041
            try {
384✔
2042
                //now that the plugin has been registered, run the build with just this file
2043
                await this.build({
384✔
2044
                    files: [file]
2045
                });
2046
            } finally {
2047
                this.plugins.remove(plugin);
384✔
2048
            }
2049
            return result;
384✔
2050
        });
2051
    }
2052
    private getTranspiledFileContentsPipeline = new ActionPipeline();
2,506✔
2053

2054
    /**
2055
     * Get the absolute output path for a file
2056
     */
2057
    private getOutputPath(file: { pkgPath?: string }, outDir = this.getOutDir()) {
×
2058
        return s`${outDir}/${file.pkgPath}`;
2,371✔
2059
    }
2060

2061
    private getOutDir(outDir?: string) {
2062
        let result = outDir ?? this.options.outDir ?? this.options.outDir;
903!
2063
        if (!result) {
903!
2064
            result = rokuDeploy.getOptions(this.options as any).outDir;
×
2065
        }
2066
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
903!
2067
        return result;
903✔
2068
    }
2069

2070
    /**
2071
     * Prepare the program for building
2072
     * @param files the list of files that should be prepared
2073
     */
2074
    private async prepare(files: BscFile[]) {
2075
        const programEvent: PrepareProgramEvent = {
452✔
2076
            program: this,
2077
            editor: this.editor,
2078
            files: files
2079
        };
2080

2081
        //assign an editor to every file
2082
        for (const file of programEvent.files) {
452✔
2083
            //if the file doesn't have an editor yet, assign one now
2084
            if (!file.editor) {
915✔
2085
                file.editor = new Editor();
868✔
2086
            }
2087
        }
2088

2089
        //sort the entries to make transpiling more deterministic
2090
        programEvent.files.sort((a, b) => {
452✔
2091
            if (a.pkgPath < b.pkgPath) {
479✔
2092
                return -1;
413✔
2093
            } else if (a.pkgPath > b.pkgPath) {
66!
2094
                return 1;
66✔
2095
            } else {
2096
                return 1;
×
2097
            }
2098
        });
2099

2100
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
452✔
2101
        await this.plugins.emitAsync('prepareProgram', programEvent);
452✔
2102

2103
        const outDir = this.getOutDir();
452✔
2104

2105
        const entries: TranspileObj[] = [];
452✔
2106

2107
        for (const file of files) {
452✔
2108
            const scope = this.getFirstScopeForFile(file);
915✔
2109
            //link the symbol table for all the files in this scope
2110
            scope?.linkSymbolTable();
915✔
2111

2112
            //if the file doesn't have an editor yet, assign one now
2113
            if (!file.editor) {
915!
2114
                file.editor = new Editor();
×
2115
            }
2116
            const event = {
915✔
2117
                program: this,
2118
                file: file,
2119
                editor: file.editor,
2120
                scope: scope,
2121
                outputPath: this.getOutputPath(file, outDir)
2122
            } as PrepareFileEvent & { outputPath: string };
2123

2124
            await this.plugins.emitAsync('beforePrepareFile', event);
915✔
2125
            await this.plugins.emitAsync('prepareFile', event);
915✔
2126
            await this.plugins.emitAsync('afterPrepareFile', event);
915✔
2127

2128
            //TODO remove this in v1
2129
            entries.push(event);
915✔
2130

2131
            //unlink the symbolTable so the next loop iteration can link theirs
2132
            scope?.unlinkSymbolTable();
915✔
2133
        }
2134

2135
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
452✔
2136
        return files;
452✔
2137
    }
2138

2139
    /**
2140
     * Generate the contents of every file
2141
     */
2142
    private async serialize(files: BscFile[]) {
2143

2144
        const allFiles = new Map<BscFile, SerializedFile[]>();
451✔
2145

2146
        //exclude prunable files if that option is enabled
2147
        if (this.options.pruneEmptyCodeFiles === true) {
451✔
2148
            files = files.filter(x => x.canBePruned !== true);
9✔
2149
        }
2150

2151
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
451✔
2152
            program: this,
2153
            files: files,
2154
            result: allFiles
2155
        });
2156
        await this.plugins.emitAsync('serializeProgram', serializeProgramEvent);
451✔
2157

2158
        // serialize each file
2159
        for (const file of files) {
451✔
2160
            let scope = this.getFirstScopeForFile(file);
912✔
2161

2162
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
2163
            if (!scope) {
912✔
2164
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
463✔
2165
                scope.getAllFiles = () => [file];
2,319✔
2166
                scope.getOwnFiles = scope.getAllFiles;
463✔
2167
            }
2168

2169
            //link the symbol table for all the files in this scope
2170
            scope?.linkSymbolTable();
912!
2171
            const event: SerializeFileEvent = {
912✔
2172
                program: this,
2173
                file: file,
2174
                scope: scope,
2175
                result: allFiles
2176
            };
2177
            await this.plugins.emitAsync('beforeSerializeFile', event);
912✔
2178
            await this.plugins.emitAsync('serializeFile', event);
912✔
2179
            await this.plugins.emitAsync('afterSerializeFile', event);
912✔
2180
            //unlink the symbolTable so the next loop iteration can link theirs
2181
            scope?.unlinkSymbolTable();
912!
2182
        }
2183

2184
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
451✔
2185

2186
        return allFiles;
451✔
2187
    }
2188

2189
    /**
2190
     * Write the entire project to disk
2191
     */
2192
    private async write(outDir: string, files: Map<BscFile, SerializedFile[]>) {
2193
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
451✔
2194
            program: this,
2195
            files: files,
2196
            outDir: outDir
2197
        });
2198
        //empty the out directory
2199
        await fsExtra.emptyDir(outDir);
451✔
2200

2201
        const serializedFiles = [...files]
451✔
2202
            .map(([, serializedFiles]) => serializedFiles)
912✔
2203
            .flat();
2204

2205
        //write all the files to disk (asynchronously)
2206
        await Promise.all(
451✔
2207
            serializedFiles.map(async (file) => {
2208
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,456✔
2209
                    program: this,
2210
                    file: file,
2211
                    outputPath: this.getOutputPath(file, outDir),
2212
                    processedFiles: new Set<SerializedFile>()
2213
                });
2214

2215
                await this.plugins.emitAsync('writeFile', event);
1,456✔
2216

2217
                await this.plugins.emitAsync('afterWriteFile', event);
1,456✔
2218
            })
2219
        );
2220

2221
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
451✔
2222
    }
2223

2224
    private buildPipeline = new ActionPipeline();
2,506✔
2225

2226
    /**
2227
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
2228
     * @param options the list of options used to build the program
2229
     */
2230
    public async build(options?: ProgramBuildOptions) {
2231
        //run a single build at a time
2232
        await this.buildPipeline.run(async () => {
451✔
2233
            const outDir = this.getOutDir(options?.outDir);
451✔
2234

2235
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
451✔
2236
                program: this,
2237
                editor: this.editor,
2238
                files: options?.files ?? Object.values(this.files)
2,706✔
2239
            });
2240

2241
            //prepare the program (and files) for building
2242
            event.files = await this.prepare(event.files);
451✔
2243

2244
            //stage the entire program
2245
            const serializedFilesByFile = await this.serialize(event.files);
451✔
2246

2247
            await this.write(outDir, serializedFilesByFile);
451✔
2248

2249
            await this.plugins.emitAsync('afterBuildProgram', event);
451✔
2250

2251
            //undo all edits for the program
2252
            this.editor.undoAll();
451✔
2253
            //undo all edits for each file
2254
            for (const file of event.files) {
451✔
2255
                file.editor.undoAll();
913✔
2256
            }
2257
        });
2258

2259
        this.logger.debug('Types Created', TypesCreated);
451✔
2260
        let totalTypesCreated = 0;
451✔
2261
        for (const key in TypesCreated) {
451✔
2262
            if (TypesCreated.hasOwnProperty(key)) {
14,420!
2263
                totalTypesCreated += TypesCreated[key];
14,420✔
2264

2265
            }
2266
        }
2267
        this.logger.info('Total Types Created', totalTypesCreated);
451✔
2268
    }
2269

2270

2271
    /**
2272
     * Find a list of files in the program that have a function with the given name (case INsensitive)
2273
     */
2274
    public findFilesForFunction(functionName: string) {
2275
        const files = [] as BscFile[];
33✔
2276
        const lowerFunctionName = functionName.toLowerCase();
33✔
2277
        //find every file with this function defined
2278
        for (const file of Object.values(this.files)) {
33✔
2279
            if (isBrsFile(file)) {
123✔
2280
                //TODO handle namespace-relative function calls
2281
                //if the file has a function with this name
2282
                // eslint-disable-next-line @typescript-eslint/dot-notation
2283
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
88✔
2284
                    files.push(file);
24✔
2285
                }
2286
            }
2287
        }
2288
        return files;
33✔
2289
    }
2290

2291
    /**
2292
     * Find a list of files in the program that have a class with the given name (case INsensitive)
2293
     */
2294
    public findFilesForClass(className: string) {
2295
        const files = [] as BscFile[];
33✔
2296
        const lowerClassName = className.toLowerCase();
33✔
2297
        //find every file with this class defined
2298
        for (const file of Object.values(this.files)) {
33✔
2299
            if (isBrsFile(file)) {
123✔
2300
                //TODO handle namespace-relative classes
2301
                //if the file has a function with this name
2302

2303
                // eslint-disable-next-line @typescript-eslint/dot-notation
2304
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
88✔
2305
                    files.push(file);
3✔
2306
                }
2307
            }
2308
        }
2309
        return files;
33✔
2310
    }
2311

2312
    public findFilesForNamespace(name: string) {
2313
        const files = [] as BscFile[];
33✔
2314
        const lowerName = name.toLowerCase();
33✔
2315
        //find every file with this class defined
2316
        for (const file of Object.values(this.files)) {
33✔
2317
            if (isBrsFile(file)) {
123✔
2318

2319
                // eslint-disable-next-line @typescript-eslint/dot-notation
2320
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
88✔
2321
                    const namespaceName = x.name.toLowerCase();
14✔
2322
                    return (
14✔
2323
                        //the namespace name matches exactly
2324
                        namespaceName === lowerName ||
18✔
2325
                        //the full namespace starts with the name (honoring the part boundary)
2326
                        namespaceName.startsWith(lowerName + '.')
2327
                    );
2328
                })) {
2329
                    files.push(file);
12✔
2330
                }
2331
            }
2332
        }
2333

2334
        return files;
33✔
2335
    }
2336

2337
    public findFilesForEnum(name: string) {
2338
        const files = [] as BscFile[];
34✔
2339
        const lowerName = name.toLowerCase();
34✔
2340
        //find every file with this enum defined
2341
        for (const file of Object.values(this.files)) {
34✔
2342
            if (isBrsFile(file)) {
124✔
2343
                // eslint-disable-next-line @typescript-eslint/dot-notation
2344
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
89✔
2345
                    files.push(file);
1✔
2346
                }
2347
            }
2348
        }
2349
        return files;
34✔
2350
    }
2351

2352
    private _manifest: Map<string, string>;
2353

2354
    /**
2355
     * The absolute source path to the manifest file. Set when loadManifest is called.
2356
     */
2357
    public manifestPath: string;
2358

2359
    /**
2360
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
2361
     * @param parsedManifest The manifest map to read from and modify
2362
     */
2363
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
2364
        // Lift the bs_consts defined in the manifest
2365
        let bsConsts = getBsConst(parsedManifest, false);
512✔
2366

2367
        // Override or delete any bs_consts defined in the bs config
2368
        for (const key in this.options?.manifest?.bs_const) {
512!
2369
            const value = this.options.manifest.bs_const[key];
3✔
2370
            if (value === null) {
3✔
2371
                bsConsts.delete(key);
1✔
2372
            } else {
2373
                bsConsts.set(key, value);
2✔
2374
            }
2375
        }
2376

2377
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
2378
        let constString = '';
512✔
2379
        for (const [key, value] of bsConsts) {
512✔
2380
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
490✔
2381
        }
2382

2383
        // Set the updated bs_const value
2384
        parsedManifest.set('bs_const', constString);
512✔
2385
    }
2386

2387
    /**
2388
     * Try to find and load the manifest into memory
2389
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
2390
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
2391
     */
2392
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
2,035✔
2393
        //if we already have a manifest instance, and should not replace...then don't replace
2394
        if (!replaceIfAlreadyLoaded && this._manifest) {
2,063!
2395
            return;
×
2396
        }
2397
        let manifestPath = manifestFileObj
2,063✔
2398
            ? manifestFileObj.src
2,063✔
2399
            : path.join(this.options.rootDir, 'manifest');
2400

2401
        //store the resolved manifest path so it can be used externally for change detection
2402
        this.manifestPath = util.standardizePath(manifestPath);
2,063✔
2403

2404
        try {
2,063✔
2405
            // we only load this manifest once, so do it sync to improve speed downstream
2406
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
2,063✔
2407
            const parsedManifest = parseManifest(contents);
512✔
2408
            this.buildBsConstsIntoParsedManifest(parsedManifest);
512✔
2409
            this._manifest = parsedManifest;
512✔
2410
        } catch (e) {
2411
            this._manifest = new Map();
1,551✔
2412
        }
2413
    }
2414

2415
    /**
2416
     * Get a map of the manifest information
2417
     */
2418
    public getManifest() {
2419
        if (!this._manifest) {
3,196✔
2420
            this.loadManifest();
2,034✔
2421
        }
2422
        return this._manifest;
3,196✔
2423
    }
2424

2425
    public dispose() {
2426
        this.plugins.emit('beforeRemoveProgram', { program: this });
2,335✔
2427

2428
        for (let filePath in this.files) {
2,335✔
2429
            this.files[filePath]?.dispose?.();
2,946!
2430
        }
2431
        for (let name in this.scopes) {
2,335✔
2432
            this.scopes[name]?.dispose?.();
4,881!
2433
        }
2434
        this.globalScope?.dispose?.();
2,335!
2435
        this.dependencyGraph?.dispose?.();
2,335!
2436
        this.plugins.emit('removeProgram', { program: this });
2,335✔
2437
        this.plugins.emit('afterRemoveProgram', { program: this });
2,335✔
2438
    }
2439
}
2440

2441
export interface FileTranspileResult {
2442
    srcPath: string;
2443
    destPath: string;
2444
    pkgPath: string;
2445
    code: string;
2446
    map: string;
2447
    typedef: string;
2448
}
2449

2450

2451
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2452
    constructor(
2453
        public program: Program,
3,317✔
2454
        public srcPath: string,
3,317✔
2455
        public destPath: string,
3,317✔
2456
        public data: LazyFileData,
3,317✔
2457
        public fileFactory: FileFactory
3,317✔
2458
    ) {
2459
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
3,317!
2460
    }
2461

2462
    public srcExtension: string;
2463

2464
    public files: TFile[] = [];
3,317✔
2465
}
2466

2467
export interface ProgramBuildOptions {
2468
    /**
2469
     * The directory where the final built files should be placed. This directory will be cleared before running
2470
     */
2471
    outDir?: string;
2472
    /**
2473
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2474
     * Typically you will want to leave this blank
2475
     */
2476
    files?: BscFile[];
2477
}
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