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

rokucommunity / brighterscript / 28445188414

30 Jun 2026 12:43PM UTC coverage: 86.869%. First build
28445188414

Pull #1739

github

web-flow
Merge a6070b5dc into 0d91ee47f
Pull Request #1739: Merge master into v1

16105 of 19568 branches covered (82.3%)

Branch coverage included in aggregate %.

139 of 183 new or added lines in 13 files covered. (75.96%)

16853 of 18372 relevant lines covered (91.73%)

27723.95 hits per line

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

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

71
const bslibNonAliasedRokuModulesPkgPath = s`source/roku_modules/rokucommunity_bslib/bslib.brs`;
1✔
72
const bslibAliasedRokuModulesPkgPath = s`source/roku_modules/bslib/bslib.brs`;
1✔
73

74
export interface SignatureInfoObj {
75
    index: number;
76
    key: string;
77
    signature: SignatureInformation;
78
}
79

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

95
        //try to find a location for the diagnostic if it doesn't have one
96
        this.diagnostics.locationResolver = (args) => {
2,651✔
97

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

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

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

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

120
            //we couldn't find any locations for the file, so just return undefined
121
            return undefined;
×
122
        };
123

124
        // initialize the diagnostics Manager
125
        this.diagnostics.logger = this.logger;
2,651✔
126
        this.diagnostics.options = this.options;
2,651✔
127
        this.diagnostics.program = this;
2,651✔
128

129
        //inject the bsc plugin as the first plugin in the stack.
130
        this.plugins.addFirst(new BscPlugin());
2,651✔
131

132
        //normalize the root dir path
133
        this.options.rootDir = util.getRootDir(this.options);
2,651✔
134

135
        this.createGlobalScope();
2,651✔
136

137
        this.fileFactory = new FileFactory(this);
2,651✔
138
    }
139

140
    public options: FinalizedBsConfig;
141
    public logger: Logger;
142

143
    /**
144
     * 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`)
145
     */
146
    public editor = new Editor();
2,651✔
147

148
    /**
149
     * A factory that creates `File` instances
150
     */
151
    private fileFactory: FileFactory;
152

153
    private createGlobalScope() {
154
        //create the 'global' scope
155
        this.globalScope = new Scope('global', this, 'scope:global');
2,651✔
156
        this.globalScope.attachDependencyGraph(this.dependencyGraph);
2,651✔
157
        this.scopes.global = this.globalScope;
2,651✔
158

159
        this.populateGlobalSymbolTable();
2,651✔
160
        this.globalScope.symbolTable.addSibling(this.componentsTable);
2,651✔
161

162
        //hardcode the files list for global scope to only contain the global file
163
        this.globalScope.getAllFiles = () => [globalFile];
16,713✔
164
        globalFile.isValidated = true;
2,651✔
165
        this.globalScope.validate();
2,651✔
166

167
        //TODO we might need to fix this because the isValidated clears stuff now
168
        (this.globalScope as any).isValidated = true;
2,651✔
169
    }
170

171

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

200
        return nodeType;
495,737✔
201
    }
202
    /**
203
     * Do all setup required for the global symbol table.
204
     */
205
    private populateGlobalSymbolTable() {
206
        //Setup primitive types in global symbolTable
207

208
        const builtInSymbolData: ExtraSymbolData = { isBuiltIn: true };
2,651✔
209

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

221
        BuiltInInterfaceAdder.getLookupTable = () => this.globalScope.symbolTable;
750,429✔
222

223
        for (const callable of globalCallables) {
2,651✔
224
            this.globalScope.symbolTable.addSymbol(callable.name, { ...builtInSymbolData, description: callable.shortDescription }, callable.type, SymbolTypeFlag.runtime);
204,127✔
225
        }
226

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

234
        for (const componentData of Object.values(components) as BRSComponentData[]) {
2,651✔
235
            let roComponentType: BscType;
236
            const lowerComponentName = componentData.name.toLowerCase();
177,617✔
237

238
            if (lowerComponentName === 'rosgnode') {
177,617✔
239
                // we will add `roSGNode` as shorthand for `roSGNodeNode`, since all roSgNode components are SceneGraph nodes
240
                continue;
2,651✔
241
            }
242
            if (lowerComponentName === 'rofunction') {
174,966✔
243
                roComponentType = new roFunctionType();
2,651✔
244
            } else {
245
                roComponentType = new InterfaceType(componentData.name);
172,315✔
246
            }
247
            roComponentType.addBuiltInInterfaces();
174,966✔
248
            roComponentType.isBuiltIn = true;
174,966✔
249
            this.globalScope.symbolTable.addSymbol(componentData.name, { ...builtInSymbolData, description: componentData.description }, roComponentType, SymbolTypeFlag.typetime);
174,966✔
250
        }
251

252
        for (const nodeData of Object.values(nodes) as SGNodeData[]) {
2,651✔
253
            this.recursivelyAddNodeToSymbolTable(nodeData);
257,147✔
254
        }
255

256
        for (const eventData of Object.values(events) as BRSEventData[]) {
2,651✔
257
            const eventType = new InterfaceType(eventData.name);
47,718✔
258
            eventType.addBuiltInInterfaces();
47,718✔
259
            eventType.isBuiltIn = true;
47,718✔
260
            this.globalScope.symbolTable.addSymbol(eventData.name, { ...builtInSymbolData, description: eventData.description }, eventType, SymbolTypeFlag.typetime);
47,718✔
261
        }
262

263
    }
264

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

273
    public diagnostics: DiagnosticManager;
274

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

281
    /**
282
     * Plugins which can provide extra diagnostics or transform AST
283
     */
284
    public plugins: PluginInterface;
285

286
    private fileSymbolInformation = new Map<string, { provides: ProvidedSymbolInfo; requires: UnresolvedSymbol[] }>();
2,651✔
287

288
    private currentScopeValidationOptions: ScopeValidationOptions;
289

290
    /**
291
     *  Map of typetime symbols which depend upon the key symbol
292
     */
293
    private symbolDependencies = new Map<string, Set<string>>();
2,651✔
294

295

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

304
    public addFileSymbolInfo(file: BrsFile) {
305
        this.fileSymbolInformation.set(file.pkgPath, {
2,474✔
306
            provides: file.providedSymbols,
307
            requires: file.requiredSymbols
308
        });
309
    }
310

311
    public getFileSymbolInfo(file: BrsFile) {
312
        return this.fileSymbolInformation.get(file.pkgPath);
2,479✔
313
    }
314

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

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

327
            //default to the embedded version
328
        } else {
329
            return `${this.options.bslibDestinationDir}${path.sep}bslib.brs`;
3,100✔
330
        }
331
    }
332

333
    public get bslibPrefix() {
334
        if (this.bslibPkgPath === bslibNonAliasedRokuModulesPkgPath) {
2,268✔
335
            return 'rokucommunity_bslib';
18✔
336
        } else {
337
            return 'bslib';
2,250✔
338
        }
339
    }
340

341

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

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

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

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

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

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

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

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

496
    private scopes = {} as Record<string, Scope>;
2,651✔
497

498
    protected addScope(scope: Scope) {
499
        this.scopes[scope.name] = scope;
2,787✔
500
        delete this.sortedScopeNames;
2,787✔
501
    }
502

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

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

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

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

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

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

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

592
        this.syncComponentDependencyGraph(arr);
16✔
593
        this.addDeferredComponentTypeSymbolCreation(xmlFile);
16✔
594
    }
595

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

609
    private getComponentKey(xmlFile: XmlFile) {
610
        return (xmlFile.componentName?.text ?? xmlFile.pkgPath).toLowerCase();
1,601✔
611
    }
612

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

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

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

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

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

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

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

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

714
    /**
715
     * Get the list of errors for the entire program.
716
     */
717
    public getDiagnostics() {
718
        return this.diagnostics.getDiagnostics();
1,788✔
719
    }
720

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

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

745
    /**
746
     * Return all scopes
747
     */
748
    public getScopes() {
749
        return Object.values(this.scopes);
13✔
750
    }
751

752
    /**
753
     * Find the scope for the specified component
754
     */
755
    public getComponentScope(componentName: string) {
756
        return this.getComponent(componentName)?.scope;
1,041✔
757
    }
758

759
    /**
760
     * Update internal maps with this file reference
761
     */
762
    private assignFile<T extends BscFile = BscFile>(file: T) {
763
        const fileAddEvent: BeforeAddFileEvent = {
3,420✔
764
            file: file,
765
            program: this
766
        };
767
        this.scopesPerFile.clear();
3,420✔
768

769
        this.plugins.emit('beforeAddFile', fileAddEvent);
3,420✔
770

771
        this.files[file.srcPath.toLowerCase()] = file;
3,420✔
772
        this.destMap.set(file.destPath.toLowerCase(), file);
3,420✔
773

774
        this.plugins.emit('afterAddFile', fileAddEvent);
3,420✔
775

776
        return file;
3,420✔
777
    }
778

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

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

806
        //namespace contributions for the new/replaced file may differ; force the
807
        //program-level contributors map to rebuild on next query
808
        this.invalidateNamespaceContributorCache();
3,416✔
809

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

816
            const data = new LazyFileData(fileData);
3,416✔
817

818
            const event = new ProvideFileEventInternal(this, srcPath, destPath, data, this.fileFactory);
3,416✔
819

820
            this.plugins.emit('beforeProvideFile', event);
3,416✔
821
            this.plugins.emit('provideFile', event);
3,416✔
822
            this.plugins.emit('afterProvideFile', event);
3,416✔
823

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

836
            //find the file instance for the srcPath that triggered this action.
837
            const primaryFile = event.files.find(x => x.srcPath === srcPath);
3,416✔
838

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

847
            //link the virtual files to the primary file
848
            this.fileClusters.set(primaryFile.srcPath?.toLowerCase(), event.files);
3,416!
849

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

862
                //set the dependencyGraph key for every file to its destPath
863
                file.dependencyGraphKey = file.destPath.toLowerCase();
3,420✔
864

865
                this.assignFile(file);
3,420✔
866

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

875
                //register this file (and its dependencies) with the dependency graph
876
                this.dependencyGraph.addOrReplace(file.dependencyGraphKey, file.dependencies ?? []);
3,420✔
877

878
                //if this is a `source` file, add it to the source scope's dependency list
879
                if (this.isSourceBrsFile(file)) {
3,420✔
880
                    this.createSourceScope();
2,367✔
881
                    this.dependencyGraph.addDependency('scope:source', file.dependencyGraphKey);
2,367✔
882
                }
883

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

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

901
                    //notify plugins that the scope is created and the component is registered
902
                    this.plugins.emit('afterProvideScope', {
540✔
903
                        program: this,
904
                        scope: scope
905
                    });
906
                }
907
            }
908

909
            return primaryFile;
3,416✔
910
        });
911
        return file as T;
3,416✔
912
    }
913

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

923
        assert.ok(fileParam, 'fileParam is required');
3,668✔
924

925
        //lift the path vars from the incoming param
926
        if (typeof fileParam === 'string') {
3,668✔
927
            fileParam = this.removePkgPrefix(fileParam);
3,059✔
928
            srcPath = s`${path.resolve(rootDir, fileParam)}`;
3,059✔
929
            destPath = s`${util.replaceCaseInsensitive(srcPath, rootDir, '')}`;
3,059✔
930
        } else {
931
            let param: any = fileParam;
609✔
932

933
            if (param.src) {
609✔
934
                srcPath = s`${param.src}`;
608✔
935
            }
936
            if (param.srcPath) {
609!
937
                srcPath = s`${param.srcPath}`;
×
938
            }
939
            if (param.dest) {
609✔
940
                destPath = s`${this.removePkgPrefix(param.dest)}`;
608✔
941
            }
942
            if (param.pkgPath) {
609!
943
                destPath = s`${this.removePkgPrefix(param.pkgPath)}`;
×
944
            }
945
        }
946

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

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

961
        assert.ok(srcPath, 'fileEntry.src is required');
3,668✔
962
        assert.ok(destPath, 'fileEntry.dest is required');
3,668✔
963

964
        return {
3,668✔
965
            srcPath: srcPath,
966
            //remove leading slash
967
            destPath: destPath.replace(/^[\/\\]+/, '')
968
        };
969
    }
970

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

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

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

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

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

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

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

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

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

1042
            const event: BeforeRemoveFileEvent = { file: file, program: this };
249✔
1043
            this.plugins.emit('beforeRemoveFile', event);
249✔
1044

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

1064
            this.dependencyGraph.remove(file.dependencyGraphKey);
249✔
1065

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

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

1079
            this.diagnostics.clearForFile(file.srcPath);
249✔
1080

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

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

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

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

1100
    public crossScopeValidation = new CrossScopeValidator(this);
2,651✔
1101

1102
    private isFirstValidation = true;
2,651✔
1103

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

1113
    } = {
1114
            brsFilesValidated: [],
1115
            xmlFilesValidated: [],
1116
            changedSymbols: new Map<SymbolTypeFlag, Set<string>>(),
1117
            changedComponentTypes: [],
1118
            scopesToInvalidate: [],
1119
            scopesToValidate: [],
1120
            filesToBeValidatedInScopeContext: new Set<BscFile>()
1121
        };
1122

1123
    public lastValidationInfo: {
2,651✔
1124
        brsFilesSrcPath: Set<string>;
1125
        xmlFilesSrcPath: Set<string>;
1126
        scopeNames: Set<string>;
1127
        componentsRebuilt: Set<string>;
1128
    } = {
1129
            brsFilesSrcPath: new Set<string>(),
1130
            xmlFilesSrcPath: new Set<string>(),
1131
            scopeNames: new Set<string>(),
1132
            componentsRebuilt: new Set<string>()
1133
        };
1134

1135
    /**
1136
     * Counter used to track which validation run is being logged
1137
     */
1138
    private validationRunSequence = 1;
2,651✔
1139

1140
    /**
1141
     * How many milliseconds can pass while doing synchronous operations in validate before we register a short timeout (i.e. yield to the event loop)
1142
     */
1143
    private validationMinSyncDuration = 75;
2,651✔
1144

1145
    private validatePromise: Promise<void> | undefined;
1146

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

1156
        let previousValidationPromise = this.validatePromise;
2,282✔
1157
        const deferred = new Deferred();
2,282✔
1158

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

1165
            //we are not async but there's a pending promise, then we cannot run this validation
1166
        } else if (previousValidationPromise !== undefined) {
1,914!
1167
            throw new Error('Cannot run synchronous validation while an async validation is in progress');
×
1168
        }
1169

1170
        let beforeValidateProgramWasEmitted = false;
2,282✔
1171

1172
        const brsFilesValidated: BrsFile[] = this.validationDetails.brsFilesValidated;
2,282✔
1173
        const xmlFilesValidated: XmlFile[] = this.validationDetails.xmlFilesValidated;
2,282✔
1174
        const changedSymbols = this.validationDetails.changedSymbols;
2,282✔
1175
        const changedComponentTypes = this.validationDetails.changedComponentTypes;
2,282✔
1176
        const scopesToInvalidate = this.validationDetails.scopesToInvalidate;
2,282✔
1177
        const scopesToValidate = this.validationDetails.scopesToValidate;
2,282✔
1178
        const filesToBeValidatedInScopeContext = this.validationDetails.filesToBeValidatedInScopeContext;
2,282✔
1179

1180
        //validate every file
1181

1182
        let logValidateEnd = (status?: string) => { };
2,282✔
1183

1184
        //will be populated later on during the corresponding sequencer event
1185
        let filesToProcess: BscFile[];
1186

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

1289
                // update the map of typetime dependencies
1290
                for (const file of brsFilesValidated) {
2,244✔
1291
                    for (const [symbolName, provided] of file.providedSymbols.symbolMap.get(SymbolTypeFlag.typetime).entries()) {
2,416✔
1292
                        // clear existing dependencies
1293
                        for (const values of this.symbolDependencies.values()) {
842✔
1294
                            values.delete(symbolName);
63✔
1295
                        }
1296

1297
                        // map types to the set of types that depend upon them
1298
                        for (const dependentSymbol of provided.requiredSymbolNames?.values() ?? []) {
842!
1299
                            const dependentSymbolLower = dependentSymbol.toLowerCase();
212✔
1300
                            if (!this.symbolDependencies.has(dependentSymbolLower)) {
212✔
1301
                                this.symbolDependencies.set(dependentSymbolLower, new Set<string>());
190✔
1302
                            }
1303
                            const symbolsDependentUpon = this.symbolDependencies.get(dependentSymbolLower);
212✔
1304
                            symbolsDependentUpon.add(symbolName);
212✔
1305
                        }
1306
                    }
1307
                }
1308

1309
                for (const flag of [SymbolTypeFlag.runtime, SymbolTypeFlag.typetime]) {
2,244✔
1310
                    const changedSymbolsSetArr = changedSymbolsMapArr.map(symMap => symMap.get(flag));
4,832✔
1311
                    const changedSymbolSet = new Set<string>();
4,488✔
1312
                    for (const changeSet of changedSymbolsSetArr) {
4,488✔
1313
                        for (const change of changeSet) {
4,832✔
1314
                            changedSymbolSet.add(change);
4,493✔
1315
                        }
1316
                    }
1317
                    if (!changedSymbols.has(flag)) {
4,488✔
1318
                        changedSymbols.set(flag, changedSymbolSet);
4,334✔
1319
                    } else {
1320
                        changedSymbols.set(flag, new Set([...changedSymbols.get(flag), ...changedSymbolSet]));
154✔
1321
                    }
1322
                }
1323

1324
                // update changed symbol set with any changed component
1325
                for (const changedComponentType of changedComponentTypes) {
2,244✔
1326
                    changedSymbols.get(SymbolTypeFlag.typetime).add(changedComponentType);
474✔
1327
                }
1328

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

1348
                changedSymbols.set(SymbolTypeFlag.typetime, new Set([...changedSymbols.get(SymbolTypeFlag.typetime), ...changedTypeSymbols, ...dependentTypesChanged]));
2,244✔
1349

1350
                this.lastValidationInfo.brsFilesSrcPath = new Set<string>(this.validationDetails.brsFilesValidated.map(f => f.srcPath?.toLowerCase() ?? ''));
2,414!
1351
                this.lastValidationInfo.xmlFilesSrcPath = new Set<string>(this.validationDetails.xmlFilesValidated.map(f => f.srcPath?.toLowerCase() ?? ''));
2,244!
1352

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

1368
                this.crossScopeValidation.buildComponentsMap();
2,224✔
1369
                this.logger.time(LogLevel.info, ['addDiagnosticsForScopes'], () => {
2,224✔
1370
                    this.crossScopeValidation.addDiagnosticsForScopes(scopesToCheck);
2,224✔
1371
                });
1372
                if (!this.isFirstValidation) {
2,224✔
1373
                    const filesToRevalidate = this.crossScopeValidation.getFilesRequiringChangedSymbol(scopesToCheck, changedSymbols);
218✔
1374
                    for (const file of filesToRevalidate) {
218✔
1375
                        filesToBeValidatedInScopeContext.add(file);
174✔
1376
                    }
1377
                }
1378

1379
                this.currentScopeValidationOptions = {
2,224✔
1380
                    filesToBeValidatedInScopeContext: filesToBeValidatedInScopeContext,
1381
                    changedSymbols: changedSymbols,
1382
                    changedFiles: Array.from(filesToBeValidatedInScopeContext),
1383
                    initialValidation: this.isFirstValidation
1384
                };
1385

1386
                //can reset changedComponent types
1387
                this.validationDetails.changedComponentTypes = [];
2,224✔
1388
            })
1389
            .forEach('invalidating file segments', () => filesToBeValidatedInScopeContext, (file) => {
2,224✔
1390
                if (isBrsFile(file)) {
3,059✔
1391
                    file.validationSegmenter.unValidateAllSegments();
2,574✔
1392
                    if (!this.isFirstValidation) {
2,574✔
1393
                        scopesToInvalidate.push(...this.getScopesForFile(file));
324✔
1394
                    }
1395
                }
1396
            })
1397
            .once('invalidate affected scopes', () => {
1398
                if (this.isFirstValidation) {
2,168✔
1399
                    for (const scope of this.getAllUserScopes()) {
1,950✔
1400
                        scope.invalidate();
2,211✔
1401
                    }
1402
                } else {
1403
                    for (const scope of scopesToInvalidate) {
218✔
1404
                        scope.invalidate();
491✔
1405
                    }
1406
                }
1407
            })
1408
            .once('checking scopes to validate', () => {
1409
                //sort the scope names so we get consistent results
1410
                for (const scope of this.getAllUserScopes()) {
2,168✔
1411
                    if (scope.shouldValidate(this.currentScopeValidationOptions)) {
2,600✔
1412
                        scopesToValidate.push(scope);
2,521✔
1413
                    }
1414
                }
1415
                this.lastValidationInfo.scopeNames = new Set<string>(scopesToValidate.map(s => s.name?.toLowerCase() ?? ''));
2,529!
1416
            })
1417
            .forEach('beforeScopeValidate', () => scopesToValidate, (scope) => {
2,168✔
1418
                this.plugins.emit('beforeValidateScope', {
2,529✔
1419
                    program: this,
1420
                    scope: scope
1421
                });
1422
            })
1423
            .forEach('validate scope', () => scopesToValidate, (scope) => {
2,167✔
1424
                scope.validate(this.currentScopeValidationOptions);
2,525✔
1425
            })
1426
            .forEach('afterValidateScope', () => scopesToValidate, (scope) => {
2,162✔
1427
                this.plugins.emit('afterValidateScope', {
2,517✔
1428
                    program: this,
1429
                    scope: scope
1430
                });
1431
            })
1432
            .once('detect duplicate component names', () => {
1433
                this.detectDuplicateComponentNames();
2,161✔
1434

1435

1436
            })
1437
            .onCancel(() => {
1438
                logValidateEnd('cancelled');
121✔
1439
            })
1440
            .onSuccess(() => {
1441
                logValidateEnd();
2,161✔
1442
                this.isFirstValidation = false;
2,161✔
1443

1444
                // can reset other validation details
1445
                this.validationDetails.changedSymbols = new Map<SymbolTypeFlag, Set<string>>();
2,161✔
1446
                this.validationDetails.scopesToInvalidate = [];
2,161✔
1447
                this.validationDetails.scopesToValidate = [];
2,161✔
1448
                this.validationDetails.filesToBeValidatedInScopeContext = new Set<BscFile>();
2,161✔
1449
            })
1450
            .onComplete(() => {
1451
                //if we emitted the beforeValidateProgram hook, emit the afterValidateProgram hook as well
1452
                if (beforeValidateProgramWasEmitted) {
2,282✔
1453
                    const wasCancelled = options?.cancellationToken?.isCancellationRequested ?? false;
2,268✔
1454
                    this.plugins.emit('afterValidateProgram', {
2,268✔
1455
                        program: this,
1456
                        wasCancelled: wasCancelled
1457
                    });
1458
                }
1459

1460
                //log all the sequencer timing metrics if `info` logging is enabled
1461
                this.logger.info(
2,282✔
1462
                    sequencer.formatMetrics({
1463
                        header: 'Program.validate metrics:',
1464
                        //only include loop iterations if `debug` logging is enabled
1465
                        includeLoopIterations: this.logger.isLogLevelEnabled(LogLevel.debug)
1466
                    })
1467
                );
1468

1469
                //regardless of the success of the validation, mark this run as complete
1470
                deferred.resolve();
2,282✔
1471
                //clear the validatePromise which means we're no longer running a validation
1472
                this.validatePromise = undefined;
2,282✔
1473
            });
1474

1475
        //run the sequencer in async mode if enabled
1476
        if (options?.async) {
2,282✔
1477
            return sequencer.run();
368✔
1478

1479
            //run the sequencer in sync mode
1480
        } else {
1481
            return sequencer.runSync();
1,914✔
1482
        }
1483
    }
1484

1485
    private getScopesForCrossScopeValidation(someComponentTypeChanged: boolean, didProvidedSymbolChange: boolean) {
1486
        const scopesForCrossScopeValidation: Scope[] = [];
2,224✔
1487
        for (let scope of this.getAllUserScopes()) {
2,224✔
1488
            if (this.globalScope === scope) {
2,600!
1489
                continue;
×
1490
            }
1491
            if (someComponentTypeChanged) {
2,600✔
1492
                scopesForCrossScopeValidation.push(scope);
671✔
1493
            }
1494
            if (didProvidedSymbolChange && !scope.isValidated) {
2,600✔
1495
                scopesForCrossScopeValidation.push(scope);
2,383✔
1496
            }
1497
        }
1498
        return scopesForCrossScopeValidation;
2,224✔
1499
    }
1500

1501
    private doesXmlFileRequireProvidedSymbols(file: XmlFile, providedSymbolsByFlag: Map<SymbolTypeFlag, Set<string>>) {
1502
        for (const required of file.requiredSymbols) {
189✔
1503
            const symbolNameLower = (required as UnresolvedXMLSymbol).name.toLowerCase();
5✔
1504
            const requiredSymbolIsProvided = providedSymbolsByFlag.get(required.flags).has(symbolNameLower);
5✔
1505
            if (requiredSymbolIsProvided) {
5✔
1506
                return true;
4✔
1507
            }
1508
        }
1509
        return false;
185✔
1510
    }
1511

1512
    /**
1513
     * Flag all duplicate component names
1514
     */
1515
    private detectDuplicateComponentNames() {
1516
        const componentsByName = Object.keys(this.files).reduce<Record<string, XmlFile[]>>((map, filePath) => {
2,161✔
1517
            const file = this.files[filePath];
3,425✔
1518
            //if this is an XmlFile, and it has a valid `componentName` property
1519
            if (isXmlFile(file) && file.componentName?.text) {
3,425✔
1520
                let lowerName = file.componentName.text.toLowerCase();
693✔
1521
                if (!map[lowerName]) {
693✔
1522
                    map[lowerName] = [];
690✔
1523
                }
1524
                map[lowerName].push(file);
693✔
1525
            }
1526
            return map;
3,425✔
1527
        }, {});
1528

1529
        for (let name in componentsByName) {
2,161✔
1530
            const xmlFiles = componentsByName[name];
690✔
1531
            //add diagnostics for every duplicate component with this name
1532
            if (xmlFiles.length > 1) {
690✔
1533
                for (let xmlFile of xmlFiles) {
3✔
1534
                    const { componentName } = xmlFile;
6✔
1535
                    this.diagnostics.register({
6✔
1536
                        ...DiagnosticMessages.duplicateComponentName(componentName.text),
1537
                        location: xmlFile.componentName.location,
1538
                        relatedInformation: xmlFiles.filter(x => x !== xmlFile).map(x => {
12✔
1539
                            return {
6✔
1540
                                location: x.componentName.location,
1541
                                message: 'Also defined here'
1542
                            };
1543
                        })
1544
                    }, { tags: [ProgramValidatorDiagnosticsTag] });
1545
                }
1546
            }
1547
        }
1548
    }
1549

1550
    /**
1551
     * Get the files for a list of filePaths
1552
     * @param filePaths can be an array of srcPath or a destPath strings
1553
     * @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
1554
     */
1555
    public getFiles<T extends BscFile>(filePaths: string[], normalizePath = true) {
33✔
1556
        return filePaths
33✔
1557
            .map(filePath => this.getFile(filePath, normalizePath))
39✔
1558
            .filter(file => file !== undefined) as T[];
39✔
1559
    }
1560

1561
    private getFilePathCache = new Map<string, { path: string; isDestMap?: boolean }>();
2,651✔
1562

1563
    /**
1564
     * Get the file at the given path
1565
     * @param filePath can be a srcPath or a destPath
1566
     * @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
1567
     */
1568
    public getFile<T extends BscFile>(filePath: string, normalizePath = true) {
25,346✔
1569
        if (this.getFilePathCache.has(filePath)) {
32,591✔
1570
            const cachedFilePath = this.getFilePathCache.get(filePath);
18,329✔
1571
            if (cachedFilePath.isDestMap) {
18,329✔
1572
                return this.destMap.get(
14,383✔
1573
                    cachedFilePath.path
1574
                ) as T;
1575
            }
1576
            return this.files[
3,946✔
1577
                cachedFilePath.path
1578
            ] as T;
1579
        }
1580
        if (typeof filePath !== 'string') {
14,262✔
1581
            return undefined;
4,701✔
1582
            //is the path absolute (or the `virtual:` prefix)
1583
        } else if (/^(?:(?:virtual:[\/\\])|(?:\w:)|(?:[\/\\]))/gmi.exec(filePath)) {
9,561✔
1584
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
4,527!
1585
            this.getFilePathCache.set(filePath, { path: standardizedPath });
4,527✔
1586

1587
            return this.files[
4,527✔
1588
                standardizedPath
1589
            ] as T;
1590
        } else if (util.isUriLike(filePath)) {
5,034✔
1591
            const path = URI.parse(filePath).fsPath;
771✔
1592
            const standardizedPath = (normalizePath ? util.standardizePath(path) : path).toLowerCase();
771!
1593
            this.getFilePathCache.set(filePath, { path: standardizedPath });
771✔
1594

1595
            return this.files[
771✔
1596
                standardizedPath
1597
            ] as T;
1598
        } else {
1599
            const standardizedPath = (normalizePath ? util.standardizePath(filePath) : filePath).toLowerCase();
4,263✔
1600
            this.getFilePathCache.set(filePath, { path: standardizedPath, isDestMap: true });
4,263✔
1601
            return this.destMap.get(
4,263✔
1602
                standardizedPath
1603
            ) as T;
1604
        }
1605
    }
1606

1607
    private sortedScopeNames: string[] = undefined;
2,651✔
1608

1609
    /**
1610
     * Gets a sorted list of all scopeNames, always beginning with "global", "source", then any others in alphabetical order
1611
     */
1612
    private getSortedScopeNames() {
1613
        if (!this.sortedScopeNames) {
6,231✔
1614
            this.sortedScopeNames = Object.keys(this.scopes).sort((a, b) => {
1,885✔
1615
                if (a === 'global') {
2,607!
1616
                    return -1;
×
1617
                } else if (b === 'global') {
2,607✔
1618
                    return 1;
1,900✔
1619
                }
1620
                if (a === 'source') {
707✔
1621
                    return -1;
38✔
1622
                } else if (b === 'source') {
669✔
1623
                    return 1;
196✔
1624
                }
1625
                if (a < b) {
473✔
1626
                    return -1;
198✔
1627
                } else if (b < a) {
275!
1628
                    return 1;
275✔
1629
                }
1630
                return 0;
×
1631
            });
1632
        }
1633
        return this.sortedScopeNames;
6,231✔
1634
    }
1635

1636
    public getAllUserScopes() {
1637
        return Object.values(this.scopes).filter(s => s.name !== 'global');
13,753✔
1638
    }
1639

1640

1641
    private scopesPerFile: Map<BscFile, Scope[]> = new Map();
2,651✔
1642

1643

1644
    /**
1645
     * Get a list of all scopes the file is loaded into
1646
     * @param file the file
1647
     */
1648
    public getScopesForFile(file: BscFile | string) {
1649
        const resolvedFile = typeof file === 'string' ? this.getFile(file) : file;
1,174✔
1650

1651
        const cachedResult = this.scopesPerFile.get(resolvedFile);
1,174✔
1652
        if (cachedResult) {
1,174✔
1653
            return cachedResult;
484✔
1654
        }
1655

1656
        let result = [] as Scope[];
690✔
1657
        if (resolvedFile) {
690✔
1658
            const scopeKeys = this.getSortedScopeNames();
689✔
1659
            for (let key of scopeKeys) {
689✔
1660
                let scope = this.scopes[key];
11,737✔
1661

1662
                if (scope.hasFile(resolvedFile)) {
11,737✔
1663
                    result.push(scope);
857✔
1664
                }
1665
            }
1666
        }
1667
        this.scopesPerFile.set(resolvedFile, result);
690✔
1668
        return result;
690✔
1669
    }
1670

1671
    /**
1672
     * Get the first found scope for a file.
1673
     */
1674
    public getFirstScopeForFile(file: BscFile): Scope | undefined {
1675
        const scopeKeys = this.getSortedScopeNames();
5,542✔
1676
        for (let key of scopeKeys) {
5,542✔
1677
            let scope = this.scopes[key];
21,652✔
1678

1679
            if (scope.hasFile(file)) {
21,652✔
1680
                return scope;
4,134✔
1681
            }
1682
        }
1683
    }
1684

1685
    public getStatementsByName(name: string, originFile: BrsFile, namespaceName?: string): FileLink<Statement>[] {
1686
        let results = new Map<Statement, FileLink<Statement>>();
46✔
1687
        const filesSearched = new Set<BrsFile>();
46✔
1688
        let lowerNamespaceName = namespaceName?.toLowerCase();
46✔
1689
        let lowerName = name?.toLowerCase();
46!
1690

1691
        function addToResults(statement: FunctionStatement | MethodStatement, file: BrsFile) {
1692
            let parentNamespaceName = statement.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(originFile.parseMode)?.toLowerCase();
111✔
1693
            if (statement.tokens.name.text.toLowerCase() === lowerName && (!lowerNamespaceName || parentNamespaceName === lowerNamespaceName)) {
111✔
1694
                if (!results.has(statement)) {
42!
1695
                    results.set(statement, { item: statement, file: file as BrsFile });
42✔
1696
                }
1697
            }
1698
        }
1699

1700
        //look through all files in scope for matches
1701
        for (const scope of this.getScopesForFile(originFile)) {
46✔
1702
            for (const file of scope.getAllFiles()) {
46✔
1703
                //skip non-brs files, or files we've already processed
1704
                if (!isBrsFile(file) || filesSearched.has(file)) {
52✔
1705
                    continue;
3✔
1706
                }
1707
                filesSearched.add(file);
49✔
1708

1709
                file.ast.walk(createVisitor({
49✔
1710
                    FunctionStatement: (statement: FunctionStatement) => {
1711
                        addToResults(statement, file);
108✔
1712
                    },
1713
                    MethodStatement: (statement: MethodStatement) => {
1714
                        addToResults(statement, file);
3✔
1715
                    }
1716
                }), {
1717
                    walkMode: WalkMode.visitStatements
1718
                });
1719
            }
1720
        }
1721
        return [...results.values()];
46✔
1722
    }
1723

1724
    public getStatementsForXmlFile(scope: XmlScope, filterName?: string): FileLink<FunctionStatement>[] {
1725
        let results = new Map<Statement, FileLink<FunctionStatement>>();
14✔
1726
        const filesSearched = new Set<BrsFile>();
14✔
1727

1728
        //get all function names for the xml file and parents
1729
        let funcNames = new Set<string>();
14✔
1730
        let currentScope = scope;
14✔
1731
        while (isXmlScope(currentScope)) {
14✔
1732
            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
20✔
1733
                if (!filterName || name === filterName) {
20!
1734
                    funcNames.add(name);
20✔
1735
                }
1736
            }
1737
            currentScope = currentScope.getParentScope() as XmlScope;
16✔
1738
        }
1739

1740
        //look through all files in scope for matches
1741
        for (const file of scope.getOwnFiles()) {
14✔
1742
            //skip non-brs files, or files we've already processed
1743
            if (!isBrsFile(file) || filesSearched.has(file)) {
28✔
1744
                continue;
14✔
1745
            }
1746
            filesSearched.add(file);
14✔
1747

1748
            file.ast.walk(createVisitor({
14✔
1749
                FunctionStatement: (statement: FunctionStatement) => {
1750
                    if (funcNames.has(statement.tokens.name.text)) {
19!
1751
                        if (!results.has(statement)) {
19!
1752
                            results.set(statement, { item: statement, file: file });
19✔
1753
                        }
1754
                    }
1755
                }
1756
            }), {
1757
                walkMode: WalkMode.visitStatements
1758
            });
1759
        }
1760
        return [...results.values()];
14✔
1761
    }
1762

1763
    /**
1764
     * Find all available completion items at the given position
1765
     * @param filePath can be a srcPath or a destPath
1766
     * @param position the position (line & column) where completions should be found
1767
     */
1768
    public getCompletions(filePath: string, position: Position) {
1769
        let file = this.getFile(filePath);
131✔
1770
        if (!file) {
131!
1771
            return [];
×
1772
        }
1773

1774
        const event: ProvideCompletionsEvent = {
131✔
1775
            program: this,
1776
            file: file,
1777
            scopes: this.getScopesForFile(file),
1778
            position: position,
1779
            completions: []
1780
        };
1781

1782
        this.plugins.emit('beforeProvideCompletions', event);
131✔
1783

1784
        this.plugins.emit('provideCompletions', event);
131✔
1785

1786
        this.plugins.emit('afterProvideCompletions', event);
131✔
1787

1788
        return event.completions;
131✔
1789
    }
1790

1791
    /**
1792
     * Goes through each file and builds a list of workspace symbols for the program. Used by LanguageServer's onWorkspaceSymbol functionality
1793
     */
1794
    public getWorkspaceSymbols() {
1795
        const event: ProvideWorkspaceSymbolsEvent = {
22✔
1796
            program: this,
1797
            workspaceSymbols: []
1798
        };
1799
        this.plugins.emit('beforeProvideWorkspaceSymbols', event);
22✔
1800
        this.plugins.emit('provideWorkspaceSymbols', event);
22✔
1801
        this.plugins.emit('afterProvideWorkspaceSymbols', event);
22✔
1802
        return event.workspaceSymbols;
22✔
1803
    }
1804

1805
    /**
1806
     * Given a position in a file, if the position is sitting on some type of identifier,
1807
     * go to the definition of that identifier (where this thing was first defined)
1808
     */
1809
    public getDefinition(srcPath: string, position: Position): Location[] {
1810
        let file = this.getFile(srcPath);
24✔
1811
        if (!file) {
24!
1812
            return [];
×
1813
        }
1814

1815
        const event: ProvideDefinitionEvent = {
24✔
1816
            program: this,
1817
            file: file,
1818
            position: position,
1819
            definitions: []
1820
        };
1821

1822
        this.plugins.emit('beforeProvideDefinition', event);
24✔
1823
        this.plugins.emit('provideDefinition', event);
24✔
1824
        this.plugins.emit('afterProvideDefinition', event);
24✔
1825
        return event.definitions;
24✔
1826
    }
1827

1828
    /**
1829
     * Get hover information for a file and position
1830
     */
1831
    public getHover(srcPath: string, position: Position): Hover[] {
1832
        let file = this.getFile(srcPath);
97✔
1833
        let result: Hover[];
1834
        if (file) {
97!
1835
            const event = {
97✔
1836
                program: this,
1837
                file: file,
1838
                position: position,
1839
                scopes: this.getScopesForFile(file),
1840
                hovers: []
1841
            } as ProvideHoverEvent;
1842
            this.plugins.emit('beforeProvideHover', event);
97✔
1843
            this.plugins.emit('provideHover', event);
97✔
1844
            this.plugins.emit('afterProvideHover', event);
97✔
1845
            result = event.hovers;
97✔
1846
        }
1847

1848
        return result ?? [];
97!
1849
    }
1850

1851
    /**
1852
     * Get full list of document symbols for a file
1853
     * @param srcPath path to the file
1854
     */
1855
    public getDocumentSymbols(srcPath: string): DocumentSymbol[] | undefined {
1856
        let file = this.getFile(srcPath);
24✔
1857
        if (file) {
24!
1858
            const event: ProvideDocumentSymbolsEvent = {
24✔
1859
                program: this,
1860
                file: file,
1861
                documentSymbols: []
1862
            };
1863
            this.plugins.emit('beforeProvideDocumentSymbols', event);
24✔
1864
            this.plugins.emit('provideDocumentSymbols', event);
24✔
1865
            this.plugins.emit('afterProvideDocumentSymbols', event);
24✔
1866
            return event.documentSymbols;
24✔
1867
        } else {
1868
            return undefined;
×
1869
        }
1870
    }
1871

1872
    /**
1873
     * Get the selection ranges for the given positions in a file. Used for expand/shrink selection.
1874
     * @param srcPath path to the file
1875
     * @param positions the positions to get selection ranges for
1876
     */
1877
    public getSelectionRanges(srcPath: string, positions: Position[]): SelectionRange[] {
1878
        const file = this.getFile(srcPath);
15✔
1879
        if (file) {
15✔
1880
            const event: ProvideSelectionRangesEvent = {
14✔
1881
                program: this,
1882
                file: file,
1883
                positions: positions,
1884
                selectionRanges: []
1885
            };
1886
            this.plugins.emit('beforeProvideSelectionRanges', event);
14✔
1887
            this.plugins.emit('provideSelectionRanges', event);
14✔
1888
            this.plugins.emit('afterProvideSelectionRanges', event);
14✔
1889
            return event.selectionRanges;
14✔
1890
        }
1891
        return [];
1✔
1892
    }
1893

1894
    /**
1895
     * Get inlay hints for the given file and range.
1896
     */
1897
    public getInlayHints(srcPath: string, range: Range): InlayHint[] {
1898
        const file = this.getFile(srcPath);
11✔
1899
        if (file) {
11!
1900
            const event: ProvideInlayHintsEvent = {
11✔
1901
                program: this,
1902
                file: file,
1903
                range: range,
1904
                scopes: this.getScopesForFile(file),
1905
                inlayHints: []
1906
            };
1907
            this.plugins.emit('beforeProvideInlayHints', event);
11✔
1908
            this.plugins.emit('provideInlayHints', event);
11✔
1909
            this.plugins.emit('afterProvideInlayHints', event);
11✔
1910
            return event.inlayHints;
11✔
1911
        }
1912
        return [];
×
1913
    }
1914

1915
    /**
1916
     * Compute code actions for the given file and range
1917
     */
1918
    public getCodeActions(srcPath: string, range: Range) {
1919
        const codeActions = [] as CodeAction[];
72✔
1920
        const file = this.getFile(srcPath);
72✔
1921
        if (file) {
72✔
1922
            const fileUri = util.pathToUri(file?.srcPath);
71!
1923
            const diagnostics = this
71✔
1924
                //get all current diagnostics (filtered by diagnostic filters)
1925
                .getDiagnostics()
1926
                //only keep diagnostics related to this file
1927
                .filter(x => x.location?.uri === fileUri)
142!
1928
                //only keep diagnostics that touch this range
1929
                .filter(x => util.rangesIntersectOrTouch(x.location.range, range));
124✔
1930

1931
            const scopes = this.getScopesForFile(file);
71✔
1932

1933
            this.plugins.emit('beforeProvideCodeActions', {
71✔
1934
                program: this,
1935
                file: file,
1936
                range: range,
1937
                diagnostics: diagnostics,
1938
                scopes: scopes,
1939
                codeActions: codeActions
1940
            });
1941

1942
            this.plugins.emit('provideCodeActions', {
71✔
1943
                program: this,
1944
                file: file,
1945
                range: range,
1946
                diagnostics: diagnostics,
1947
                scopes: scopes,
1948
                codeActions: codeActions
1949
            });
1950

1951
            this.plugins.emit('afterProvideCodeActions', {
71✔
1952
                program: this,
1953
                file: file,
1954
                range: range,
1955
                diagnostics: diagnostics,
1956
                scopes: scopes,
1957
                codeActions: codeActions
1958
            });
1959
        }
1960
        return codeActions;
72✔
1961
    }
1962

1963
    /**
1964
     * Compute "source fix all" code actions for the given file.
1965
     * Fires the `onGetSourceFixAllCodeActions` plugin event with all diagnostics for the file (no range filter),
1966
     * then converts each contributed SourceFixAllCodeAction into an LSP CodeAction.
1967
     */
1968
    public getSourceFixAllCodeActions(srcPath: string): CodeAction[] {
1969
        const actions: SourceFixAllCodeAction[] = [];
×
1970
        const file = this.getFile(srcPath);
×
1971
        if (file) {
×
1972
            const fileUri = util.pathToUri(file.srcPath);
×
1973
            const diagnostics = this
×
1974
                .getDiagnostics()
1975
                .filter(x => x.location?.uri === fileUri);
×
1976
            const scopes = this.getScopesForFile(file);
×
1977
            this.plugins.emit('onGetSourceFixAllCodeActions', {
×
1978
                program: this,
1979
                file: file,
1980
                diagnostics: diagnostics,
1981
                scopes: scopes,
1982
                actions: actions
1983
            } as OnGetSourceFixAllCodeActionsEvent);
1984
        }
1985
        return actions.map(action => codeActionUtil.createCodeAction({
×
1986
            ...action,
1987
            kind: action.kind ?? 'source.fixAll.brighterscript' as any
×
1988
        }));
1989
    }
1990

1991
    /**
1992
     * Get semantic tokens for the specified file
1993
     */
1994
    public getSemanticTokens(srcPath: string): SemanticToken[] | undefined {
1995
        const file = this.getFile(srcPath);
26✔
1996
        if (file) {
26!
1997
            this.plugins.emit('beforeProvideSemanticTokens', {
26✔
1998
                program: this,
1999
                file: file,
2000
                scopes: this.getScopesForFile(file),
2001
                semanticTokens: undefined
2002
            });
2003
            const result = [] as SemanticToken[];
26✔
2004
            this.plugins.emit('provideSemanticTokens', {
26✔
2005
                program: this,
2006
                file: file,
2007
                scopes: this.getScopesForFile(file),
2008
                semanticTokens: result
2009
            });
2010
            this.plugins.emit('afterProvideSemanticTokens', {
26✔
2011
                program: this,
2012
                file: file,
2013
                scopes: this.getScopesForFile(file),
2014
                semanticTokens: result
2015
            });
2016
            return result;
26✔
2017
        }
2018
    }
2019

2020
    public getSignatureHelp(filePath: string, position: Position): SignatureInfoObj[] {
2021
        let file: BrsFile = this.getFile(filePath);
188✔
2022
        if (!file || !isBrsFile(file)) {
188✔
2023
            return [];
3✔
2024
        }
2025
        let callExpressionInfo = new CallExpressionInfo(file, position);
185✔
2026
        let signatureHelpUtil = new SignatureHelpUtil();
185✔
2027
        return signatureHelpUtil.getSignatureHelpItems(callExpressionInfo);
185✔
2028
    }
2029

2030
    public getReferences(srcPath: string, position: Position): Location[] {
2031
        //find the file
2032
        let file = this.getFile(srcPath);
4✔
2033

2034
        const event: ProvideReferencesEvent = {
4✔
2035
            program: this,
2036
            file: file,
2037
            position: position,
2038
            references: []
2039
        };
2040

2041
        this.plugins.emit('beforeProvideReferences', event);
4✔
2042
        this.plugins.emit('provideReferences', event);
4✔
2043
        this.plugins.emit('afterProvideReferences', event);
4✔
2044

2045
        return event.references;
4✔
2046
    }
2047

2048
    /**
2049
     * Transpile a single file and get the result as a string.
2050
     * This does not write anything to the file system.
2051
     *
2052
     * This should only be called by `LanguageServer`.
2053
     * Internal usage should call `_getTranspiledFileContents` instead.
2054
     * @param filePath can be a srcPath or a destPath
2055
     */
2056
    public async getTranspiledFileContents(filePath: string): Promise<FileTranspileResult> {
2057
        const file = this.getFile(filePath);
385✔
2058

2059
        return this.getTranspiledFileContentsPipeline.run(async () => {
385✔
2060

2061
            const result = {
385✔
2062
                destPath: file.destPath,
2063
                pkgPath: file.pkgPath,
2064
                srcPath: file.srcPath
2065
            } as FileTranspileResult;
2066

2067
            const expectedPkgPath = file.pkgPath.toLowerCase();
385✔
2068
            const expectedMapPath = `${expectedPkgPath}.map`;
385✔
2069
            const expectedTypedefPkgPath = expectedPkgPath.replace(/\.brs$/i, '.d.bs');
385✔
2070

2071
            //add a temporary plugin to tap into the file writing process
2072
            const plugin = this.plugins.addFirst({
385✔
2073
                name: 'getTranspiledFileContents',
2074
                beforeWriteFile: (event) => {
2075
                    const pkgPath = event.file.pkgPath.toLowerCase();
1,248✔
2076
                    switch (pkgPath) {
1,248✔
2077
                        //this is the actual transpiled file
2078
                        case expectedPkgPath:
1,248✔
2079
                            result.code = event.file.data.toString();
385✔
2080
                            break;
385✔
2081
                        //this is the sourcemap
2082
                        case expectedMapPath:
2083
                            result.map = event.file.data.toString();
229✔
2084
                            break;
229✔
2085
                        //this is the typedef
2086
                        case expectedTypedefPkgPath:
2087
                            result.typedef = event.file.data.toString();
10✔
2088
                            break;
10✔
2089
                        default:
2090
                        //no idea what this file is. just ignore it
2091
                    }
2092
                    //mark every file as processed so it they don't get written to the output directory
2093
                    event.processedFiles.add(event.file);
1,248✔
2094
                }
2095
            });
2096

2097
            try {
385✔
2098
                //now that the plugin has been registered, run the build with just this file
2099
                await this.build({
385✔
2100
                    files: [file]
2101
                });
2102
            } finally {
2103
                this.plugins.remove(plugin);
385✔
2104
            }
2105
            return result;
385✔
2106
        });
2107
    }
2108
    private getTranspiledFileContentsPipeline = new ActionPipeline();
2,651✔
2109

2110
    /**
2111
     * Get the absolute output path for a file
2112
     */
2113
    private getOutputPath(file: { pkgPath?: string }, outDir = this.getOutDir()) {
×
2114
        return s`${outDir}/${file.pkgPath}`;
2,377✔
2115
    }
2116

2117
    private getOutDir(outDir?: string) {
2118
        let result = outDir ?? this.options.outDir ?? this.options.outDir;
905!
2119
        if (!result) {
905!
2120
            result = rokuDeploy.getOptions(this.options as any).outDir;
×
2121
        }
2122
        result = s`${path.resolve(this.options.cwd ?? process.cwd(), result ?? '/')}`;
905!
2123
        return result;
905✔
2124
    }
2125

2126
    /**
2127
     * Prepare the program for building
2128
     * @param files the list of files that should be prepared
2129
     */
2130
    private async prepare(files: BscFile[]) {
2131
        const programEvent: PrepareProgramEvent = {
453✔
2132
            program: this,
2133
            editor: this.editor,
2134
            files: files
2135
        };
2136

2137
        //assign an editor to every file
2138
        for (const file of programEvent.files) {
453✔
2139
            //if the file doesn't have an editor yet, assign one now
2140
            if (!file.editor) {
917✔
2141
                file.editor = new Editor();
870✔
2142
            }
2143
        }
2144

2145
        //sort the entries to make transpiling more deterministic
2146
        programEvent.files.sort((a, b) => {
453✔
2147
            if (a.pkgPath < b.pkgPath) {
480✔
2148
                return -1;
415✔
2149
            } else if (a.pkgPath > b.pkgPath) {
65!
2150
                return 1;
65✔
2151
            } else {
2152
                return 1;
×
2153
            }
2154
        });
2155

2156
        await this.plugins.emitAsync('beforePrepareProgram', programEvent);
453✔
2157
        await this.plugins.emitAsync('prepareProgram', programEvent);
453✔
2158

2159
        const outDir = this.getOutDir();
453✔
2160

2161
        const entries: TranspileObj[] = [];
453✔
2162

2163
        for (const file of files) {
453✔
2164
            const scope = this.getFirstScopeForFile(file);
917✔
2165
            //link the symbol table for all the files in this scope
2166
            scope?.linkSymbolTable();
917✔
2167

2168
            //if the file doesn't have an editor yet, assign one now
2169
            if (!file.editor) {
917!
2170
                file.editor = new Editor();
×
2171
            }
2172
            const event = {
917✔
2173
                program: this,
2174
                file: file,
2175
                editor: file.editor,
2176
                scope: scope,
2177
                outputPath: this.getOutputPath(file, outDir)
2178
            } as PrepareFileEvent & { outputPath: string };
2179

2180
            await this.plugins.emitAsync('beforePrepareFile', event);
917✔
2181
            await this.plugins.emitAsync('prepareFile', event);
917✔
2182
            await this.plugins.emitAsync('afterPrepareFile', event);
917✔
2183

2184
            //TODO remove this in v1
2185
            entries.push(event);
917✔
2186

2187
            //unlink the symbolTable so the next loop iteration can link theirs
2188
            scope?.unlinkSymbolTable();
917✔
2189
        }
2190

2191
        await this.plugins.emitAsync('afterPrepareProgram', programEvent);
453✔
2192
        return files;
453✔
2193
    }
2194

2195
    /**
2196
     * Generate the contents of every file
2197
     */
2198
    private async serialize(files: BscFile[]) {
2199

2200
        const allFiles = new Map<BscFile, SerializedFile[]>();
452✔
2201

2202
        //exclude prunable files if that option is enabled
2203
        if (this.options.pruneEmptyCodeFiles === true) {
452✔
2204
            files = files.filter(x => x.canBePruned !== true);
9✔
2205
        }
2206

2207
        const serializeProgramEvent = await this.plugins.emitAsync('beforeSerializeProgram', {
452✔
2208
            program: this,
2209
            files: files,
2210
            result: allFiles
2211
        });
2212
        await this.plugins.emitAsync('serializeProgram', serializeProgramEvent);
452✔
2213

2214
        // serialize each file
2215
        for (const file of files) {
452✔
2216
            let scope = this.getFirstScopeForFile(file);
914✔
2217

2218
            //if the file doesn't have a scope, create a temporary scope for the file so it can depend on scope-level items
2219
            if (!scope) {
914✔
2220
                scope = new Scope(`temporary-for-${file.pkgPath}`, this);
464✔
2221
                scope.getAllFiles = () => [file];
2,324✔
2222
                scope.getOwnFiles = scope.getAllFiles;
464✔
2223
            }
2224

2225
            //link the symbol table for all the files in this scope
2226
            scope?.linkSymbolTable();
914!
2227
            const event: SerializeFileEvent = {
914✔
2228
                program: this,
2229
                file: file,
2230
                scope: scope,
2231
                result: allFiles
2232
            };
2233
            await this.plugins.emitAsync('beforeSerializeFile', event);
914✔
2234
            await this.plugins.emitAsync('serializeFile', event);
914✔
2235
            await this.plugins.emitAsync('afterSerializeFile', event);
914✔
2236
            //unlink the symbolTable so the next loop iteration can link theirs
2237
            scope?.unlinkSymbolTable();
914!
2238
        }
2239

2240
        this.plugins.emit('afterSerializeProgram', serializeProgramEvent);
452✔
2241

2242
        return allFiles;
452✔
2243
    }
2244

2245
    /**
2246
     * Write the entire project to disk
2247
     */
2248
    private async write(outDir: string, files: Map<BscFile, SerializedFile[]>) {
2249
        const programEvent = await this.plugins.emitAsync('beforeWriteProgram', {
452✔
2250
            program: this,
2251
            files: files,
2252
            outDir: outDir
2253
        });
2254
        //empty the out directory
2255
        await fsExtra.emptyDir(outDir);
452✔
2256

2257
        const serializedFiles = [...files]
452✔
2258
            .map(([, serializedFiles]) => serializedFiles)
914✔
2259
            .flat();
2260

2261
        //write all the files to disk (asynchronously)
2262
        await Promise.all(
452✔
2263
            serializedFiles.map(async (file) => {
2264
                const event = await this.plugins.emitAsync('beforeWriteFile', {
1,460✔
2265
                    program: this,
2266
                    file: file,
2267
                    outputPath: this.getOutputPath(file, outDir),
2268
                    processedFiles: new Set<SerializedFile>()
2269
                });
2270

2271
                await this.plugins.emitAsync('writeFile', event);
1,460✔
2272

2273
                await this.plugins.emitAsync('afterWriteFile', event);
1,460✔
2274
            })
2275
        );
2276

2277
        await this.plugins.emitAsync('afterWriteProgram', programEvent);
452✔
2278
    }
2279

2280
    private buildPipeline = new ActionPipeline();
2,651✔
2281

2282
    /**
2283
     * Build the project. This transpiles/transforms/copies all files and moves them to the staging directory
2284
     * @param options the list of options used to build the program
2285
     */
2286
    public async build(options?: ProgramBuildOptions) {
2287
        //run a single build at a time
2288
        await this.buildPipeline.run(async () => {
452✔
2289
            const outDir = this.getOutDir(options?.outDir);
452✔
2290

2291
            const event = await this.plugins.emitAsync('beforeBuildProgram', {
452✔
2292
                program: this,
2293
                editor: this.editor,
2294
                files: options?.files ?? Object.values(this.files)
2,712✔
2295
            });
2296

2297
            //prepare the program (and files) for building
2298
            event.files = await this.prepare(event.files);
452✔
2299

2300
            //stage the entire program
2301
            const serializedFilesByFile = await this.serialize(event.files);
452✔
2302

2303
            await this.write(outDir, serializedFilesByFile);
452✔
2304

2305
            await this.plugins.emitAsync('afterBuildProgram', event);
452✔
2306

2307
            //undo all edits for the program
2308
            this.editor.undoAll();
452✔
2309
            //undo all edits for each file
2310
            for (const file of event.files) {
452✔
2311
                file.editor.undoAll();
915✔
2312
            }
2313
        });
2314

2315
        this.logger.debug('Types Created', TypesCreated);
452✔
2316
        let totalTypesCreated = 0;
452✔
2317
        for (const key in TypesCreated) {
452✔
2318
            if (TypesCreated.hasOwnProperty(key)) {
14,456!
2319
                totalTypesCreated += TypesCreated[key];
14,456✔
2320

2321
            }
2322
        }
2323
        this.logger.info('Total Types Created', totalTypesCreated);
452✔
2324
    }
2325

2326

2327
    /**
2328
     * Find a list of files in the program that have a function with the given name (case INsensitive)
2329
     */
2330
    public findFilesForFunction(functionName: string) {
2331
        const files = [] as BscFile[];
47✔
2332
        const lowerFunctionName = functionName.toLowerCase();
47✔
2333
        //find every file with this function defined
2334
        for (const file of Object.values(this.files)) {
47✔
2335
            if (isBrsFile(file)) {
137✔
2336
                //TODO handle namespace-relative function calls
2337
                //if the file has a function with this name
2338
                // eslint-disable-next-line @typescript-eslint/dot-notation
2339
                if (file['_cachedLookups'].functionStatementMap.get(lowerFunctionName)) {
102✔
2340
                    files.push(file);
24✔
2341
                }
2342
            }
2343
        }
2344
        return files;
47✔
2345
    }
2346

2347
    /**
2348
     * Find a list of files in the program that have a class with the given name (case INsensitive)
2349
     */
2350
    public findFilesForClass(className: string) {
2351
        const files = [] as BscFile[];
47✔
2352
        const lowerClassName = className.toLowerCase();
47✔
2353
        //find every file with this class defined
2354
        for (const file of Object.values(this.files)) {
47✔
2355
            if (isBrsFile(file)) {
137✔
2356
                //TODO handle namespace-relative classes
2357
                //if the file has a function with this name
2358

2359
                // eslint-disable-next-line @typescript-eslint/dot-notation
2360
                if (file['_cachedLookups'].classStatementMap.get(lowerClassName) !== undefined) {
102✔
2361
                    files.push(file);
3✔
2362
                }
2363
            }
2364
        }
2365
        return files;
47✔
2366
    }
2367

2368
    public findFilesForNamespace(name: string) {
2369
        const files = [] as BscFile[];
47✔
2370
        const lowerName = name.toLowerCase();
47✔
2371
        //find every file with this class defined
2372
        for (const file of Object.values(this.files)) {
47✔
2373
            if (isBrsFile(file)) {
137✔
2374

2375
                // eslint-disable-next-line @typescript-eslint/dot-notation
2376
                if (file['_cachedLookups'].namespaceStatements.find((x) => {
102✔
2377
                    const namespaceName = x.name.toLowerCase();
14✔
2378
                    return (
14✔
2379
                        //the namespace name matches exactly
2380
                        namespaceName === lowerName ||
18✔
2381
                        //the full namespace starts with the name (honoring the part boundary)
2382
                        namespaceName.startsWith(lowerName + '.')
2383
                    );
2384
                })) {
2385
                    files.push(file);
12✔
2386
                }
2387
            }
2388
        }
2389

2390
        return files;
47✔
2391
    }
2392

2393
    public findFilesForEnum(name: string) {
2394
        const files = [] as BscFile[];
48✔
2395
        const lowerName = name.toLowerCase();
48✔
2396
        //find every file with this enum defined
2397
        for (const file of Object.values(this.files)) {
48✔
2398
            if (isBrsFile(file)) {
138✔
2399
                // eslint-disable-next-line @typescript-eslint/dot-notation
2400
                if (file['_cachedLookups'].enumStatementMap.get(lowerName)) {
103✔
2401
                    files.push(file);
1✔
2402
                }
2403
            }
2404
        }
2405
        return files;
48✔
2406
    }
2407

2408
    private _manifest: Map<string, string>;
2409
    private _manifestEntries: ManifestEntry[];
2410

2411
    /**
2412
     * The absolute source path to the manifest file. Set when loadManifest is called.
2413
     */
2414
    public manifestPath: string;
2415

2416
    /**
2417
     * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const`
2418
     * @param parsedManifest The manifest map to read from and modify
2419
     */
2420
    private buildBsConstsIntoParsedManifest(parsedManifest: Map<string, string>) {
2421
        // Lift the bs_consts defined in the manifest
2422
        let bsConsts = getBsConst(parsedManifest, false);
547✔
2423

2424
        // Override or delete any bs_consts defined in the bs config
2425
        for (const key in this.options?.manifest?.bs_const) {
547!
2426
            const value = this.options.manifest.bs_const[key];
3✔
2427
            if (value === null) {
3✔
2428
                bsConsts.delete(key);
1✔
2429
            } else {
2430
                bsConsts.set(key, value);
2✔
2431
            }
2432
        }
2433

2434
        // convert the new list of bs consts back into a string for the rest of the down stream systems to use
2435
        let constString = '';
547✔
2436
        for (const [key, value] of bsConsts) {
547✔
2437
            constString += `${constString !== '' ? ';' : ''}${key}=${value.toString()}`;
494✔
2438
        }
2439

2440
        // Set the updated bs_const value
2441
        parsedManifest.set('bs_const', constString);
547✔
2442
    }
2443

2444
    /**
2445
     * Try to find and load the manifest into memory
2446
     * @param manifestFileObj A pointer to a potential manifest file object found during loading
2447
     * @param replaceIfAlreadyLoaded should we overwrite the internal `_manifest` if it already exists
2448
     */
2449
    public loadManifest(manifestFileObj?: FileObj, replaceIfAlreadyLoaded = true) {
2,258✔
2450
        //if we already have a manifest instance, and should not replace...then don't replace
2451
        if (!replaceIfAlreadyLoaded && this._manifest) {
2,286!
2452
            return;
×
2453
        }
2454
        let manifestPath = manifestFileObj
2,286✔
2455
            ? manifestFileObj.src
2,286✔
2456
            : path.join(this.options.rootDir, 'manifest');
2457

2458
        //store the resolved manifest path so it can be used externally for change detection
2459
        this.manifestPath = util.standardizePath(manifestPath);
2,286✔
2460

2461
        try {
2,286✔
2462
            // we only load this manifest once, so do it sync to improve speed downstream
2463
            const contents = fsExtra.readFileSync(manifestPath, 'utf-8');
2,286✔
2464
            const parsedManifest = parseManifest(contents);
547✔
2465
            this.buildBsConstsIntoParsedManifest(parsedManifest);
547✔
2466
            this._manifest = parsedManifest;
547✔
2467
            this._manifestEntries = parseManifestEntries(contents);
547✔
2468
            this._manifestPath = manifestPath;
547✔
2469
        } catch (e) {
2470
            this._manifest = new Map();
1,739✔
2471
            this._manifestEntries = [];
1,739✔
2472
            this._manifestPath = undefined;
1,739✔
2473
        }
2474
    }
2475

2476
    /**
2477
     * Get a map of the manifest information
2478
     */
2479
    public getManifest() {
2480
        if (!this._manifest) {
3,300✔
2481
            this.loadManifest();
2,090✔
2482
        }
2483
        return this._manifest;
3,300✔
2484
    }
2485

2486
    /**
2487
     * Get the manifest as a list of `{ key, value, range }` entries with line/column ranges
2488
     * suitable for attaching diagnostics to specific manifest lines.
2489
     *
2490
     * NOTE: protected for now. The shape of this data is likely to evolve as we build out
2491
     * more manifest-aware features (validation rules, autocomplete, etc.). External plugins
2492
     * shouldn't depend on this until we commit to a stable API.
2493
     */
2494
    protected getManifestEntries(): ManifestEntry[] {
2495
        if (!this._manifestEntries) {
2,269✔
2496
            this.loadManifest();
167✔
2497
        }
2498
        return this._manifestEntries;
2,269✔
2499
    }
2500

2501
    private _manifestPath: string | undefined;
2502

2503
    /**
2504
     * Returns the absolute path of the loaded manifest file, or undefined if no manifest was found.
2505
     *
2506
     * NOTE: protected for now. Once brighterscript treats the manifest as a proper file
2507
     * (with editor / BscFile integration) callers should consume that instead of poking at
2508
     * the Program. External plugins shouldn't depend on this in the meantime.
2509
     */
2510
    protected getManifestPath(): string | undefined {
2511
        if (!this._manifest) {
2,267!
NEW
2512
            this.loadManifest();
×
2513
        }
2514
        return this._manifestPath;
2,267✔
2515
    }
2516

2517
    private _minFirmwareVersion: string | undefined;
2518

2519
    /**
2520
     * The minimum Roku firmware version brighterscript should assume the user is targeting.
2521
     * If `options.minFirmwareVersion` is set AND parseable as semver, the canonical coerced
2522
     * form ("15.0" → "15.0.0") wins; otherwise (unset or unparseable) falls back to
2523
     * {@link DEFAULT_MIN_FIRMWARE_VERSION}. The return is always a valid semver string so
2524
     * downstream callers can pass it directly to `semver.gte`/`semver.lt` without re-coercing.
2525
     * Cached after first call.
2526
     */
2527
    public getMinFirmwareVersion(): string {
2528
        if (this._minFirmwareVersion === undefined) {
70✔
2529
            const userValue = this.options.minFirmwareVersion;
48✔
2530
            const coerced = userValue ? semver.coerce(userValue) : undefined;
48✔
2531
            this._minFirmwareVersion = coerced ? coerced.version : DEFAULT_MIN_FIRMWARE_VERSION;
48✔
2532
        }
2533
        return this._minFirmwareVersion;
70✔
2534
    }
2535

2536
    private _rsgVersion: string | undefined;
2537

2538
    /**
2539
     * Returns the effective `rsg_version` for this program in canonical semver form (e.g. "1.2"
2540
     * → "1.2.0"). If the manifest declares a value explicitly and it's parseable, the canonical
2541
     * form wins; otherwise (manifest silent OR value is malformed) we fall back to the highest
2542
     * known rsg_version whose `becameDefaultAt` is `<=` the effective minimum firmware version,
2543
     * driven by {@link RSG_VERSIONS}. Cached after first call.
2544
     *
2545
     * Manifest validation (format errors, etc.) happens in `ProgramValidator` against the raw
2546
     * entry — that path doesn't go through this method.
2547
     */
2548
    public getRsgVersion(): string {
2549
        if (this._rsgVersion !== undefined) {
16!
NEW
2550
            return this._rsgVersion;
×
2551
        }
2552
        const explicit = this.getManifest().get('rsg_version');
16✔
2553
        const explicitCoerced = explicit !== undefined ? semver.coerce(explicit.trim()) : undefined;
16✔
2554
        if (explicitCoerced) {
16✔
2555
            this._rsgVersion = explicitCoerced.version;
5✔
2556
            return this._rsgVersion;
5✔
2557
        }
2558
        //walk known rsg_versions in descending order (newest first) and return the first whose
2559
        //becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0'
2560
        //(currently `1.1`), this loop always finds a match.
2561
        const minFirmwareVersion = this.getMinFirmwareVersion();
11✔
2562
        const candidates = Object.entries(RSG_VERSIONS)
11✔
2563
            .filter(([, info]) => info.becameDefaultAt !== undefined)
44✔
2564
            .sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!));
33✔
2565
        for (const [version, info] of candidates) {
11✔
2566
            if (semver.gte(minFirmwareVersion, info.becameDefaultAt!)) {
25✔
2567
                this._rsgVersion = semver.coerce(version)!.version;
11✔
2568
                return this._rsgVersion;
11✔
2569
            }
2570
        }
2571
        //unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0'
NEW
2572
        this._rsgVersion = DEFAULT_MIN_FIRMWARE_VERSION;
×
NEW
2573
        return this._rsgVersion;
×
2574
    }
2575

2576
    public dispose() {
2577
        this.plugins.emit('beforeRemoveProgram', { program: this });
2,439✔
2578

2579
        for (let filePath in this.files) {
2,439✔
2580
            this.files[filePath]?.dispose?.();
3,004!
2581
        }
2582
        for (let name in this.scopes) {
2,439✔
2583
            this.scopes[name]?.dispose?.();
5,052!
2584
        }
2585
        this.globalScope?.dispose?.();
2,439!
2586
        this.dependencyGraph?.dispose?.();
2,439!
2587
        this.plugins.emit('removeProgram', { program: this });
2,439✔
2588
        this.plugins.emit('afterRemoveProgram', { program: this });
2,439✔
2589
    }
2590
}
2591

2592
export interface FileTranspileResult {
2593
    srcPath: string;
2594
    destPath: string;
2595
    pkgPath: string;
2596
    code: string;
2597
    map: string;
2598
    typedef: string;
2599
}
2600

2601

2602
class ProvideFileEventInternal<TFile extends BscFile = BscFile> implements ProvideFileEvent<TFile> {
2603
    constructor(
2604
        public program: Program,
3,416✔
2605
        public srcPath: string,
3,416✔
2606
        public destPath: string,
3,416✔
2607
        public data: LazyFileData,
3,416✔
2608
        public fileFactory: FileFactory
3,416✔
2609
    ) {
2610
        this.srcExtension = path.extname(srcPath)?.toLowerCase();
3,416!
2611
    }
2612

2613
    public srcExtension: string;
2614

2615
    public files: TFile[] = [];
3,416✔
2616
}
2617

2618
export interface ProgramBuildOptions {
2619
    /**
2620
     * The directory where the final built files should be placed. This directory will be cleared before running
2621
     */
2622
    outDir?: string;
2623
    /**
2624
     * An array of files to build. If omitted, the entire list of files from the program will be used instead.
2625
     * Typically you will want to leave this blank
2626
     */
2627
    files?: BscFile[];
2628
}
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