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

rokucommunity / brighterscript / #15915

13 May 2026 06:48PM UTC coverage: 86.904% (-0.01%) from 86.917%
#15915

push

web-flow
Merge 276684b1f into 9de11ed0c

15644 of 19004 branches covered (82.32%)

Branch coverage included in aggregate %.

4 of 9 new or added lines in 2 files covered. (44.44%)

27 existing lines in 4 files now uncovered.

16360 of 17823 relevant lines covered (91.79%)

27311.41 hits per line

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

92.09
/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, ProvideSourceFixAllCodeActionsEvent } 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;
298✔
536
            } else if (b < a) {
575!
537
                return 1;
575✔
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('addFile', fileAddEvent);
3,321✔
771

772
        this.plugins.emit('afterAddFile', fileAddEvent);
3,321✔
773

774
        return file;
3,321✔
775
    }
776

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

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

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

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

813
            const data = new LazyFileData(fileData);
3,317✔
814

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

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

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

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

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

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

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

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

862
                this.assignFile(file);
3,321✔
863

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

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

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

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

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

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

906
            return primaryFile;
3,317✔
907
        });
908
        return file as T;
3,317✔
909
    }
910

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

920
        assert.ok(fileParam, 'fileParam is required');
3,569✔
921

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1039
            const event: BeforeRemoveFileEvent = { file: file, program: this };
249✔
1040
            this.plugins.emit('beforeRemoveFile', event);
249✔
1041
            this.plugins.emit('removeFile', event);
249✔
1042

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

1062
            this.dependencyGraph.remove(file.dependencyGraphKey);
249✔
1063

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

1071
                if (!keepSymbolInformation) {
225✔
1072
                    this.fileSymbolInformation.delete(file.pkgPath);
15✔
1073
                }
1074
                this.crossScopeValidation.clearResolutionsForFile(file);
225✔
1075
            }
1076

1077
            this.diagnostics.clearForFile(file.srcPath);
249✔
1078

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

1083
                this.unregisterComponent(file);
16✔
1084
            }
1085
            this.logger.debug('Disposing file', file.srcPath);
249✔
1086

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

1094
            this.plugins.emit('afterRemoveFile', event);
249✔
1095
        }
1096
    }
1097

1098
    public crossScopeValidation = new CrossScopeValidator(this);
2,506✔
1099

1100
    private isFirstValidation = true;
2,506✔
1101

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

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

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

1131
    /**
1132
     * Counter used to track which validation run is being logged
1133
     */
1134
    private validationRunSequence = 1;
2,506✔
1135

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

1141
    private validatePromise: Promise<void> | undefined;
1142

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

1152
        let previousValidationPromise = this.validatePromise;
2,178✔
1153
        const deferred = new Deferred();
2,178✔
1154

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

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

1166
        let beforeValidateProgramWasEmitted = false;
2,178✔
1167

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

1175
        //validate every file
1176

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

1179
        //will be populated later on during the correspnding sequencer event
1180
        let filesToProcess: BscFile[];
1181

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1456
        //run the sequencer in async mode if enabled
1457
        if (options?.async) {
2,178✔
1458
            return sequencer.run();
368✔
1459

1460
            //run the sequencer in sync mode
1461
        } else {
1462
            return sequencer.runSync();
1,810✔
1463
        }
1464
    }
1465

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

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

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

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

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

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

1545
    /**
1546
     * Get the file at the given path
1547
     * @param filePath can be a srcPath or a destPath
1548
     * @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
1549
     */
1550
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
25,104✔
1551
        if (this.getFilePathCache.has(filePath)) {
33,531✔
1552
            const cachedFilePath = this.getFilePathCache.get(filePath);
19,531✔
1553
            if (cachedFilePath.isDestMap) {
19,531✔
1554
                return this.destMap.get(
15,951✔
1555
                    cachedFilePath.path
1556
                ) as T;
1557
            }
1558
            return this.files[
3,580✔
1559
                cachedFilePath.path
1560
            ] as T;
1561
        }
1562
        if (typeof filePath !== 'string') {
14,000✔
1563
            return undefined;
4,626✔
1564
            //is the path absolute (or the `virtual:` prefix)
1565
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
9,374✔
1566
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
4,430!
1567
            this.getFilePathCache.set(filePath, { path: standardizedPath });
4,430✔
1568

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

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

1589
    private sortedScopeNames: string[] = undefined;
2,506✔
1590

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

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

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

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

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

1647
            if (scope.hasFile(file)) {
21,443✔
1648
                return scope;
4,041✔
1649
            }
1650
        }
1651
    }
1652

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

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

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

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

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

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

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

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

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

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

1750
        this.plugins.emit('beforeProvideCompletions', event);
131✔
1751

1752
        this.plugins.emit('provideCompletions', event);
131✔
1753

1754
        this.plugins.emit('afterProvideCompletions', event);
131✔
1755

1756
        return event.completions;
131✔
1757
    }
1758

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

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

1783
        const event: ProvideDefinitionEvent = {
24✔
1784
            program: this,
1785
            file: file,
1786
            position: position,
1787
            definitions: []
1788
        };
1789

1790
        this.plugins.emit('beforeProvideDefinition', event);
24✔
1791
        this.plugins.emit('provideDefinition', event);
24✔
1792
        this.plugins.emit('afterProvideDefinition', event);
24✔
1793
        return event.definitions;
24✔
1794
    }
1795

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

1816
        return result ?? [];
97!
1817
    }
1818

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

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

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

1878
            const scopes = this.getScopesForFile(file);
52✔
1879

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

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

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

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

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

1971
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
1972
        let file: BrsFile = this.getFile(filePath);
188✔
1973
        if (!file || !isBrsFile(file)) {
188✔
1974
            return [];
3✔
1975
        }
1976
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
1977
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
1978
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
1979
    }
1980

1981
    public getReferences(srcPath: string, position: Position): Location[] {
1982
        //find the file
1983
        let file = this.getFile(srcPath);
4✔
1984

1985
        const event: ProvideReferencesEvent = {
4✔
1986
            program: this,
1987
            file: file,
1988
            position: position,
1989
            references: []
1990
        };
1991

1992
        this.plugins.emit('beforeProvideReferences', event);
4✔
1993
        this.plugins.emit('provideReferences', event);
4✔
1994
        this.plugins.emit('afterProvideReferences', event);
4✔
1995

1996
        return event.references;
4✔
1997
    }
1998

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

2010
        return this.getTranspiledFileContentsPipeline.run(async () => {
384✔
2011

2012
            const result = {
384✔
2013
                destPath: file.destPath,
2014
                pkgPath: file.pkgPath,
2015
                srcPath: file.srcPath
2016
            } as FileTranspileResult;
2017

2018
            const expectedPkgPath = file.pkgPath.toLowerCase();
384✔
2019
            const expectedMapPath = `${expectedPkgPath}.map`;
384✔
2020
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
384✔
2021

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

2048
            try {
384✔
2049
                //now that the plugin has been registered, run the build with just this file
2050
                await this.build({
384✔
2051
                    files: [file]
2052
                });
2053
            } finally {
2054
                this.plugins.remove(plugin);
384✔
2055
            }
2056
            return result;
384✔
2057
        });
2058
    }
2059
    private getTranspiledFileContentsPipeline = new ActionPipeline();
2,506✔
2060

2061
    /**
2062
     * Get the absolute output path for a file
2063
     */
2064
    private getOutputPath(file: { pkgPath?: string }, outDir = this.getOutDir()) {
×
2065
        return s`${outDir}/${file.pkgPath}`;
2,371✔
2066
    }
2067

2068
    private getOutDir(outDir?: string) {
2069
        let result = outDir ?? this.options.outDir ?? this.options.outDir;
903!
2070
        if (!result) {
903!
UNCOV
2071
            result = rokuDeploy.getOptions(this.options as any).outDir;
×
2072
        }
2073
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
903!
2074
        return result;
903✔
2075
    }
2076

2077
    /**
2078
     * Prepare the program for building
2079
     * @param files the list of files that should be prepared
2080
     */
2081
    private async prepare(files: BscFile[]) {
2082
        const programEvent: PrepareProgramEvent = {
452✔
2083
            program: this,
2084
            editor: this.editor,
2085
            files: files
2086
        };
2087

2088
        //assign an editor to every file
2089
        for (const file of programEvent.files) {
452✔
2090
            //if the file doesn't have an editor yet, assign one now
2091
            if (!file.editor) {
915✔
2092
                file.editor = new Editor();
868✔
2093
            }
2094
        }
2095

2096
        //sort the entries to make transpiling more deterministic
2097
        programEvent.files.sort((a, b) => {
452✔
2098
            if (a.pkgPath < b.pkgPath) {
478✔
2099
                return -1;
413✔
2100
            } else if (a.pkgPath > b.pkgPath) {
65!
2101
                return 1;
65✔
2102
            } else {
UNCOV
2103
                return 1;
×
2104
            }
2105
        });
2106

2107
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
452✔
2108
        await this.plugins.emitAsync('prepareProgram', programEvent);
452✔
2109

2110
        const outDir = this.getOutDir();
452✔
2111

2112
        const entries: TranspileObj[] = [];
452✔
2113

2114
        for (const file of files) {
452✔
2115
            const scope = this.getFirstScopeForFile(file);
915✔
2116
            //link the symbol table for all the files in this scope
2117
            scope?.linkSymbolTable();
915✔
2118

2119
            //if the file doesn't have an editor yet, assign one now
2120
            if (!file.editor) {
915!
UNCOV
2121
                file.editor = new Editor();
×
2122
            }
2123
            const event = {
915✔
2124
                program: this,
2125
                file: file,
2126
                editor: file.editor,
2127
                scope: scope,
2128
                outputPath: this.getOutputPath(file, outDir)
2129
            } as PrepareFileEvent & { outputPath: string };
2130

2131
            await this.plugins.emitAsync('beforePrepareFile', event);
915✔
2132
            await this.plugins.emitAsync('prepareFile', event);
915✔
2133
            await this.plugins.emitAsync('afterPrepareFile', event);
915✔
2134

2135
            //TODO remove this in v1
2136
            entries.push(event);
915✔
2137

2138
            //unlink the symbolTable so the next loop iteration can link theirs
2139
            scope?.unlinkSymbolTable();
915✔
2140
        }
2141

2142
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
452✔
2143
        return files;
452✔
2144
    }
2145

2146
    /**
2147
     * Generate the contents of every file
2148
     */
2149
    private async serialize(files: BscFile[]) {
2150

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

2153
        //exclude prunable files if that option is enabled
2154
        if (this.options.pruneEmptyCodeFiles === true) {
451✔
2155
            files = files.filter(x => x.canBePruned !== true);
9✔
2156
        }
2157

2158
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
451✔
2159
            program: this,
2160
            files: files,
2161
            result: allFiles
2162
        });
2163
        await this.plugins.emitAsync('serializeProgram', serializeProgramEvent);
451✔
2164

2165
        // serialize each file
2166
        for (const file of files) {
451✔
2167
            let scope = this.getFirstScopeForFile(file);
912✔
2168

2169
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
2170
            if (!scope) {
912✔
2171
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
463✔
2172
                scope.getAllFiles = () => [file];
2,319✔
2173
                scope.getOwnFiles = scope.getAllFiles;
463✔
2174
            }
2175

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

2191
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
451✔
2192

2193
        return allFiles;
451✔
2194
    }
2195

2196
    /**
2197
     * Write the entire project to disk
2198
     */
2199
    private async write(outDir: string, files: Map<BscFile, SerializedFile[]>) {
2200
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
451✔
2201
            program: this,
2202
            files: files,
2203
            outDir: outDir
2204
        });
2205

2206
        await this.plugins.emitAsync('writeProgram', programEvent);
451✔
2207

2208
        //empty the out directory
2209
        await fsExtra.emptyDir(outDir);
451✔
2210

2211
        const serializedFiles = [...files]
451✔
2212
            .map(([, serializedFiles]) => serializedFiles)
912✔
2213
            .flat();
2214

2215
        //write all the files to disk (asynchronously)
2216
        await Promise.all(
451✔
2217
            serializedFiles.map(async (file) => {
2218
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,456✔
2219
                    program: this,
2220
                    file: file,
2221
                    outputPath: this.getOutputPath(file, outDir),
2222
                    processedFiles: new Set<SerializedFile>()
2223
                });
2224

2225
                await this.plugins.emitAsync('writeFile', event);
1,456✔
2226

2227
                await this.plugins.emitAsync('afterWriteFile', event);
1,456✔
2228
            })
2229
        );
2230

2231
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
451✔
2232
    }
2233

2234
    private buildPipeline = new ActionPipeline();
2,506✔
2235

2236
    /**
2237
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
2238
     * @param options the list of options used to build the program
2239
     */
2240
    public async build(options?: ProgramBuildOptions) {
2241
        //run a single build at a time
2242
        await this.buildPipeline.run(async () => {
451✔
2243
            const outDir = this.getOutDir(options?.outDir);
451✔
2244

2245
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
451✔
2246
                program: this,
2247
                editor: this.editor,
2248
                files: options?.files ?? Object.values(this.files)
2,706✔
2249
            });
2250

2251
            await this.plugins.emitAsync('buildProgram', event);
451✔
2252

2253
            //prepare the program (and files) for building
2254
            event.files = await this.prepare(event.files);
451✔
2255

2256
            //stage the entire program
2257
            const serializedFilesByFile = await this.serialize(event.files);
451✔
2258

2259
            await this.write(outDir, serializedFilesByFile);
451✔
2260

2261
            await this.plugins.emitAsync('afterBuildProgram', event);
451✔
2262

2263
            //undo all edits for the program
2264
            this.editor.undoAll();
451✔
2265
            //undo all edits for each file
2266
            for (const file of event.files) {
451✔
2267
                file.editor.undoAll();
913✔
2268
            }
2269
        });
2270

2271
        this.logger.debug('Types Created', TypesCreated);
451✔
2272
        let totalTypesCreated = 0;
451✔
2273
        for (const key in TypesCreated) {
451✔
2274
            if (TypesCreated.hasOwnProperty(key)) {
14,420!
2275
                totalTypesCreated += TypesCreated[key];
14,420✔
2276

2277
            }
2278
        }
2279
        this.logger.info('Total Types Created', totalTypesCreated);
451✔
2280
    }
2281

2282

2283
    /**
2284
     * Find a list of files in the program that have a function with the given name (case INsensitive)
2285
     */
2286
    public findFilesForFunction(functionName: string) {
2287
        const files = [] as BscFile[];
33✔
2288
        const lowerFunctionName = functionName.toLowerCase();
33✔
2289
        //find every file with this function defined
2290
        for (const file of Object.values(this.files)) {
33✔
2291
            if (isBrsFile(file)) {
123✔
2292
                //TODO handle namespace-relative function calls
2293
                //if the file has a function with this name
2294
                // eslint-disable-next-line @typescript-eslint/dot-notation
2295
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
88✔
2296
                    files.push(file);
24✔
2297
                }
2298
            }
2299
        }
2300
        return files;
33✔
2301
    }
2302

2303
    /**
2304
     * Find a list of files in the program that have a class with the given name (case INsensitive)
2305
     */
2306
    public findFilesForClass(className: string) {
2307
        const files = [] as BscFile[];
33✔
2308
        const lowerClassName = className.toLowerCase();
33✔
2309
        //find every file with this class defined
2310
        for (const file of Object.values(this.files)) {
33✔
2311
            if (isBrsFile(file)) {
123✔
2312
                //TODO handle namespace-relative classes
2313
                //if the file has a function with this name
2314

2315
                // eslint-disable-next-line @typescript-eslint/dot-notation
2316
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
88✔
2317
                    files.push(file);
3✔
2318
                }
2319
            }
2320
        }
2321
        return files;
33✔
2322
    }
2323

2324
    public findFilesForNamespace(name: string) {
2325
        const files = [] as BscFile[];
33✔
2326
        const lowerName = name.toLowerCase();
33✔
2327
        //find every file with this class defined
2328
        for (const file of Object.values(this.files)) {
33✔
2329
            if (isBrsFile(file)) {
123✔
2330

2331
                // eslint-disable-next-line @typescript-eslint/dot-notation
2332
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
88✔
2333
                    const namespaceName = x.name.toLowerCase();
14✔
2334
                    return (
14✔
2335
                        //the namespace name matches exactly
2336
                        namespaceName === lowerName ||
18✔
2337
                        //the full namespace starts with the name (honoring the part boundary)
2338
                        namespaceName.startsWith(lowerName + '.')
2339
                    );
2340
                })) {
2341
                    files.push(file);
12✔
2342
                }
2343
            }
2344
        }
2345

2346
        return files;
33✔
2347
    }
2348

2349
    public findFilesForEnum(name: string) {
2350
        const files = [] as BscFile[];
34✔
2351
        const lowerName = name.toLowerCase();
34✔
2352
        //find every file with this enum defined
2353
        for (const file of Object.values(this.files)) {
34✔
2354
            if (isBrsFile(file)) {
124✔
2355
                // eslint-disable-next-line @typescript-eslint/dot-notation
2356
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
89✔
2357
                    files.push(file);
1✔
2358
                }
2359
            }
2360
        }
2361
        return files;
34✔
2362
    }
2363

2364
    private _manifest: Map<string, string>;
2365

2366
    /**
2367
     * The absolute source path to the manifest file. Set when loadManifest is called.
2368
     */
2369
    public manifestPath: string;
2370

2371
    /**
2372
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
2373
     * @param parsedManifest The manifest map to read from and modify
2374
     */
2375
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
2376
        // Lift the bs_consts defined in the manifest
2377
        let bsConsts = getBsConst(parsedManifest, false);
512✔
2378

2379
        // Override or delete any bs_consts defined in the bs config
2380
        for (const key in this.options?.manifest?.bs_const) {
512!
2381
            const value = this.options.manifest.bs_const[key];
3✔
2382
            if (value === null) {
3✔
2383
                bsConsts.delete(key);
1✔
2384
            } else {
2385
                bsConsts.set(key, value);
2✔
2386
            }
2387
        }
2388

2389
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
2390
        let constString = '';
512✔
2391
        for (const [key, value] of bsConsts) {
512✔
2392
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
490✔
2393
        }
2394

2395
        // Set the updated bs_const value
2396
        parsedManifest.set('bs_const', constString);
512✔
2397
    }
2398

2399
    /**
2400
     * Try to find and load the manifest into memory
2401
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
2402
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
2403
     */
2404
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
2,035✔
2405
        //if we already have a manifest instance, and should not replace...then don't replace
2406
        if (!replaceIfAlreadyLoaded && this._manifest) {
2,063!
UNCOV
2407
            return;
×
2408
        }
2409
        let manifestPath = manifestFileObj
2,063✔
2410
            ? manifestFileObj.src
2,063✔
2411
            : path.join(this.options.rootDir, 'manifest');
2412

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

2416
        try {
2,063✔
2417
            // we only load this manifest once, so do it sync to improve speed downstream
2418
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
2,063✔
2419
            const parsedManifest = parseManifest(contents);
512✔
2420
            this.buildBsConstsIntoParsedManifest(parsedManifest);
512✔
2421
            this._manifest = parsedManifest;
512✔
2422
        } catch (e) {
2423
            this._manifest = new Map();
1,551✔
2424
        }
2425
    }
2426

2427
    /**
2428
     * Get a map of the manifest information
2429
     */
2430
    public getManifest() {
2431
        if (!this._manifest) {
3,196✔
2432
            this.loadManifest();
2,034✔
2433
        }
2434
        return this._manifest;
3,196✔
2435
    }
2436

2437
    public dispose() {
2438
        this.plugins.emit('beforeRemoveProgram', { program: this });
2,335✔
2439

2440
        for (let filePath in this.files) {
2,335✔
2441
            this.files[filePath]?.dispose?.();
2,946!
2442
        }
2443
        for (let name in this.scopes) {
2,335✔
2444
            this.scopes[name]?.dispose?.();
4,881!
2445
        }
2446
        this.globalScope?.dispose?.();
2,335!
2447
        this.dependencyGraph?.dispose?.();
2,335!
2448
        this.plugins.emit('removeProgram', { program: this });
2,335✔
2449
        this.plugins.emit('afterRemoveProgram', { program: this });
2,335✔
2450
    }
2451
}
2452

2453
export interface FileTranspileResult {
2454
    srcPath: string;
2455
    destPath: string;
2456
    pkgPath: string;
2457
    code: string;
2458
    map: string;
2459
    typedef: string;
2460
}
2461

2462

2463
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2464
    constructor(
2465
        public program: Program,
3,317✔
2466
        public srcPath: string,
3,317✔
2467
        public destPath: string,
3,317✔
2468
        public data: LazyFileData,
3,317✔
2469
        public fileFactory: FileFactory
3,317✔
2470
    ) {
2471
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
3,317!
2472
    }
2473

2474
    public srcExtension: string;
2475

2476
    public files: TFile[] = [];
3,317✔
2477
}
2478

2479
export interface ProgramBuildOptions {
2480
    /**
2481
     * The directory where the final built files should be placed. This directory will be cleared before running
2482
     */
2483
    outDir?: string;
2484
    /**
2485
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2486
     * Typically you will want to leave this blank
2487
     */
2488
    files?: BscFile[];
2489
}
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