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

rokucommunity / brighterscript / #15439

24 Mar 2026 07:33PM UTC coverage: 88.983% (-0.01%) from 88.993%
#15439

push

web-flow
Merge 5b04f945e into 0c894b16d

7957 of 9428 branches covered (84.4%)

Branch coverage included in aggregate %.

72 of 78 new or added lines in 5 files covered. (92.31%)

2 existing lines in 2 files now uncovered.

10199 of 10976 relevant lines covered (92.92%)

1961.03 hits per line

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

88.13
/src/bscPlugin/validation/ScopeValidator.ts
1
import { URI } from 'vscode-uri';
1✔
2
import { isBrsFile, isCallExpression, isLiteralExpression, isNamespaceStatement, isXmlScope } from '../../astUtils/reflection';
1✔
3
import { Cache } from '../../Cache';
1✔
4
import { DiagnosticMessages } from '../../DiagnosticMessages';
1✔
5
import type { BrsFile } from '../../files/BrsFile';
6
import type { BscFile, BsDiagnostic, OnScopeValidateEvent } from '../../interfaces';
7
import type { EnumStatement, NamespaceStatement } from '../../parser/Statement';
8
import util from '../../util';
1✔
9
import { nodes, components } from '../../roku-types';
1✔
10
import type { BRSComponentData } from '../../roku-types';
11
import type { Token } from '../../lexer/Token';
12
import { TokenKind } from '../../lexer/TokenKind';
1✔
13
import type { Scope } from '../../Scope';
14
import type { DiagnosticRelatedInformation } from 'vscode-languageserver';
15
import type { Expression } from '../../parser/AstNode';
16
import type { VariableExpression, DottedGetExpression } from '../../parser/Expression';
17
import { ParseMode } from '../../parser/Parser';
1✔
18
import { createVisitor, WalkMode } from '../../astUtils/visitors';
1✔
19

20
/**
21
 * The lower-case names of all platform-included scenegraph nodes
22
 */
23
const platformNodeNames = new Set(Object.values(nodes).map(x => x.name.toLowerCase()));
97✔
24
const platformComponentNames = new Set(Object.values(components).map(x => x.name.toLowerCase()));
67✔
25

26
/**
27
 * A validator that handles all scope validations for a program validation cycle.
28
 * You should create ONE of these to handle all scope events between beforeProgramValidate and afterProgramValidate,
29
 * and call reset() before using it again in the next cycle
30
 */
31
export class ScopeValidator {
1✔
32

33
    /**
34
     * The event currently being processed. This will change multiple times throughout the lifetime of this validator
35
     */
36
    private event: OnScopeValidateEvent;
37

38
    public processEvent(event: OnScopeValidateEvent) {
39
        this.event = event;
2,366✔
40
        this.walkFiles();
2,366✔
41
        this.detectDuplicateEnums();
2,366✔
42
    }
43

44
    public reset() {
45
        this.event = undefined;
887✔
46
        this.onceCache.clear();
887✔
47
        this.multiScopeCache.clear();
887✔
48
    }
49

50
    private walkFiles() {
51
        this.event.scope.enumerateOwnFiles((file) => {
2,366✔
52
            if (isBrsFile(file)) {
2,579✔
53
                this.iterateFileExpressions(file);
2,392✔
54
                this.validateCreateObjectCalls(file);
2,392✔
55
                this.validateComputedAAKeys(file);
2,392✔
56
            }
57
        });
58
    }
59

60
    private validateComputedAAKeys(file: BrsFile) {
61
        const { scope } = this.event;
2,392✔
62
        file.ast.walk(createVisitor({
2,392✔
63
            AAIndexedMemberExpression: (member) => {
64
                // Direct string literal (e.g. ["my-key"]) is valid
65
                if (isLiteralExpression(member.key)) {
21✔
66
                    if (member.key.token.kind !== TokenKind.StringLiteral) {
4✔
67
                        this.addMultiScopeDiagnostic({
3✔
68
                            file: file,
69
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
70
                            range: member.key.range
71
                        });
72
                    }
73
                    return;
4✔
74
                }
75
                const parts = util.getDottedGetPath(member.key);
17✔
76
                if (parts.length === 0) {
17✔
77
                    this.addMultiScopeDiagnostic({
1✔
78
                        file: file,
79
                        ...DiagnosticMessages.computedPropertyKeyMustBeConstantExpression(),
80
                        range: member.key.range
81
                    });
82
                    return;
1✔
83
                }
84
                const enclosingNamespace = member.key.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript)?.toLowerCase();
16!
85
                const entityName = parts.map(p => p.name.text.toLowerCase()).join('.');
25✔
86
                // Check enum member
87
                const memberLink = scope.getEnumMemberFileLink(entityName, enclosingNamespace);
16✔
88
                if (memberLink) {
16✔
89
                    const value = memberLink.item.getValue();
7✔
90
                    if (!value?.startsWith('"')) {
7!
91
                        this.addMultiScopeDiagnostic({
2✔
92
                            file: file,
93
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
94
                            range: member.key.range
95
                        });
96
                    }
97
                    return;
7✔
98
                }
99
                // Check const — follow the chain to find the root literal type
100
                const constLink = scope.getConstFileLink(entityName, enclosingNamespace);
9✔
101
                if (constLink) {
9✔
102
                    if (!this.constResolvesToString(constLink.item.value, enclosingNamespace, scope)) {
7✔
103
                        this.addMultiScopeDiagnostic({
4✔
104
                            file: file,
105
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
106
                            range: member.key.range
107
                        });
108
                    }
109
                    return;
7✔
110
                }
111
                this.addMultiScopeDiagnostic({
2✔
112
                    file: file,
113
                    ...DiagnosticMessages.computedPropertyKeyMustBeConstantExpression(),
114
                    range: member.key.range
115
                });
116
            }
117
        }), { walkMode: WalkMode.visitAllRecursive });
118
    }
119

120
    /**
121
     * Recursively resolve a const/enum reference to determine if its ultimate value is a string.
122
     * Returns true only if the value is confirmed to be a string.
123
     */
124
    private constResolvesToString(value: Expression, enclosingNamespace: string, scope: Scope, visited = new Set<string>()): boolean {
7✔
125
        if (isLiteralExpression(value)) {
12✔
126
            return value.token.kind === TokenKind.StringLiteral;
6✔
127
        }
128
        const parts = util.getDottedGetPath(value);
6✔
129
        if (parts.length === 0) {
6!
NEW
130
            return false;
×
131
        }
132
        const entityName = parts.map(p => p.name.text.toLowerCase()).join('.');
7✔
133
        if (visited.has(entityName)) {
6✔
134
            return false; // circular reference — cannot confirm string
1✔
135
        }
136
        visited.add(entityName);
5✔
137
        const constLink = scope.getConstFileLink(entityName, enclosingNamespace);
5✔
138
        if (constLink) {
5✔
139
            return this.constResolvesToString(constLink.item.value, enclosingNamespace, scope, visited);
4✔
140
        }
141
        const memberLink = scope.getEnumMemberFileLink(entityName, enclosingNamespace);
1✔
142
        if (memberLink) {
1!
143
            return this.constResolvesToString(memberLink.item.value, enclosingNamespace, scope, visited);
1✔
144
        }
NEW
145
        return false;
×
146
    }
147

148
    private expressionsByFile = new Cache<BrsFile, Readonly<ExpressionInfo>[]>();
1,448✔
149
    private iterateFileExpressions(file: BrsFile) {
150
        const { scope } = this.event;
2,392✔
151
        //build an expression collection ONCE per file
152
        const expressionInfos = this.expressionsByFile.getOrAdd(file, () => {
2,392✔
153
            const result: DeepWriteable<ExpressionInfo[]> = [];
2,314✔
154
            const expressions = [
2,314✔
155
                ...file.parser.references.expressions,
156
                //all class "extends <whatever>" expressions
157
                ...file.parser.references.classStatements.map(x => x.parentClassName?.expression),
190✔
158
                //all interface "extends <whatever>" expressions
159
                ...file.parser.references.interfaceStatements.map(x => x.parentInterfaceName?.expression)
38✔
160
            ];
161
            for (let expression of expressions) {
2,314✔
162
                if (!expression) {
2,855✔
163
                    continue;
162✔
164
                }
165

166
                //walk left-to-right on every expression, only keep the ones that start with VariableExpression, and then keep subsequent DottedGet parts
167
                const parts = util.getDottedGetPath(expression);
2,693✔
168

169
                if (parts.length > 0) {
2,693✔
170
                    result.push({
829✔
171
                        parts: parts,
172
                        expression: expression,
173
                        enclosingNamespaceNameLower: expression.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript)?.toLowerCase()
4,974✔
174
                    });
175
                }
176
            }
177
            return result as unknown as Readonly<ExpressionInfo>[];
2,314✔
178
        });
179

180
        outer:
2,392✔
181
        for (const info of expressionInfos) {
2,392✔
182
            const symbolTable = info.expression.getSymbolTable();
854✔
183
            const firstPart = info.parts[0];
854✔
184
            const firstNamespacePart = info.parts[0].name.text;
854✔
185
            const firstNamespacePartLower = firstNamespacePart?.toLowerCase();
854!
186
            //get the namespace container (accounting for namespace-relative as well)
187
            const namespaceContainer = scope.getNamespace(firstNamespacePartLower, info.enclosingNamespaceNameLower);
854✔
188

189
            //flag all unknown left-most variables
190
            if (
854✔
191
                !symbolTable?.hasSymbol(firstPart.name?.text) &&
6,049!
192
                !namespaceContainer
193
            ) {
194
                //flag functions differently than all other variables
195
                if (isCallExpression(firstPart.parent) && firstPart.parent.callee === firstPart) {
69✔
196
                    this.addMultiScopeDiagnostic({
14✔
197
                        file: file as BscFile,
198
                        ...DiagnosticMessages.cannotFindFunction(firstPart.name?.text),
42!
199
                        range: firstPart.name.range
200
                    });
201
                } else {
202
                    this.addMultiScopeDiagnostic({
55✔
203
                        file: file as BscFile,
204
                        ...DiagnosticMessages.cannotFindName(firstPart.name?.text),
165!
205
                        range: firstPart.name.range
206
                    });
207
                }
208
                //skip to the next expression
209
                continue;
69✔
210
            }
211

212
            const enumStatement = scope.getEnum(firstNamespacePartLower, info.enclosingNamespaceNameLower);
785✔
213

214
            //if this isn't a namespace, skip it
215
            if (!namespaceContainer && !enumStatement) {
785✔
216
                continue;
625✔
217
            }
218

219
            //catch unknown namespace items
220
            let entityName = firstNamespacePart;
160✔
221
            let entityNameLower = firstNamespacePart.toLowerCase();
160✔
222
            for (let i = 1; i < info.parts.length; i++) {
160✔
223
                const part = info.parts[i];
207✔
224
                entityName += '.' + part.name.text;
207✔
225
                entityNameLower += '.' + part.name.text.toLowerCase();
207✔
226

227
                //if this is an enum member, stop validating here to prevent errors further down the chain
228
                if (scope.getEnumMemberFileLink(entityName, info.enclosingNamespaceNameLower)) {
207✔
229
                    break;
73✔
230
                }
231

232
                if (
134✔
233
                    !scope.getEnumMap().has(entityNameLower) &&
489✔
234
                    !scope.getClassMap().has(entityNameLower) &&
235
                    !scope.getConstMap().has(entityNameLower) &&
236
                    !scope.getCallableByName(entityNameLower) &&
237
                    !scope.getNamespace(entityNameLower, info.enclosingNamespaceNameLower)
238
                ) {
239
                    //if this looks like an enum, provide a nicer error message
240
                    const theEnum = this.getEnum(scope, entityNameLower)?.item;
24✔
241
                    if (theEnum) {
24✔
242
                        this.addMultiScopeDiagnostic({
14✔
243
                            file: file,
244
                            ...DiagnosticMessages.unknownEnumValue(part.name.text?.split('.').pop(), theEnum.fullName),
42!
245
                            range: part.name.range,
246
                            relatedInformation: [{
247
                                message: 'Enum declared here',
248
                                location: util.createLocation(
249
                                    URI.file(file.srcPath).toString(),
250
                                    theEnum.tokens.name.range
251
                                )
252
                            }]
253
                        });
254
                    } else {
255
                        //flag functions differently than all other variables
256
                        if (isCallExpression(firstPart.parent) && firstPart.parent.callee === firstPart) {
10!
257
                            this.addMultiScopeDiagnostic({
×
258
                                ...DiagnosticMessages.cannotFindFunction(part.name.text, entityName),
259
                                range: part.name.range,
260
                                file: file
261
                            });
262
                        } else {
263
                            this.addMultiScopeDiagnostic({
10✔
264
                                ...DiagnosticMessages.cannotFindName(part.name.text, entityName),
265
                                range: part.name.range,
266
                                file: file
267
                            });
268
                        }
269
                    }
270
                    //no need to add another diagnostic for future unknown items
271
                    continue outer;
24✔
272
                }
273
            }
274
            //if the full expression is a namespace path, this is an illegal statement because namespaces don't exist at runtme
275
            if (scope.getNamespace(entityNameLower, info.enclosingNamespaceNameLower)) {
136✔
276
                this.addMultiScopeDiagnostic({
8✔
277
                    ...DiagnosticMessages.itemCannotBeUsedAsVariable('namespace'),
278
                    range: info.expression.range,
279
                    file: file
280
                }, 'When used in scope');
281
            }
282
        }
283
    }
284

285
    /**
286
     * Given a string optionally separated by dots, find an enum related to it.
287
     * For example, all of these would return the enum: `SomeNamespace.SomeEnum.SomeMember`, SomeEnum.SomeMember, `SomeEnum`
288
     */
289
    private getEnum(scope: Scope, name: string) {
290
        //look for the enum directly
291
        let result = scope.getEnumMap().get(name);
24✔
292

293
        //assume we've been given the enum.member syntax, so pop the member and try again
294
        if (!result) {
24!
295
            const parts = name.split('.');
24✔
296
            parts.pop();
24✔
297
            result = scope.getEnumMap().get(parts.join('.'));
24✔
298
        }
299
        return result;
24✔
300
    }
301

302
    /**
303
     * Flag duplicate enums
304
     */
305
    private detectDuplicateEnums() {
306
        const diagnostics: BsDiagnostic[] = [];
2,366✔
307
        const enumLocationsByName = new Cache<string, Array<{ file: BrsFile; statement: EnumStatement }>>();
2,366✔
308
        this.event.scope.enumerateBrsFiles((file) => {
2,366✔
309
            for (const enumStatement of file.parser.references.enumStatements) {
2,402✔
310
                const fullName = enumStatement.fullName;
94✔
311
                const nameLower = fullName?.toLowerCase();
94!
312
                if (nameLower?.length > 0) {
94!
313
                    enumLocationsByName.getOrAdd(nameLower, () => []).push({
94✔
314
                        file: file,
315
                        statement: enumStatement
316
                    });
317
                }
318
            }
319
        });
320

321
        //now that we've collected all enum declarations, flag duplicates
322
        for (const enumLocations of enumLocationsByName.values()) {
2,366✔
323
            //sort by srcPath to keep the primary enum location consistent
324
            enumLocations.sort((a, b) => {
92✔
325
                const pathA = a.file?.srcPath;
2!
326
                const pathB = b.file?.srcPath;
2!
327
                if (pathA < pathB) {
2✔
328
                    return -1;
1✔
329
                } else if (pathA > pathB) {
1!
330
                    return 1;
×
331
                }
332
                return 0;
1✔
333
            });
334
            const primaryEnum = enumLocations.shift();
92✔
335
            const fullName = primaryEnum.statement.fullName;
92✔
336
            for (const duplicateEnumInfo of enumLocations) {
92✔
337
                diagnostics.push({
2✔
338
                    ...DiagnosticMessages.duplicateEnumDeclaration(this.event.scope.name, fullName),
339
                    file: duplicateEnumInfo.file,
340
                    range: duplicateEnumInfo.statement.tokens.name.range,
341
                    relatedInformation: [{
342
                        message: 'Enum declared here',
343
                        location: util.createLocation(
344
                            URI.file(primaryEnum.file.srcPath).toString(),
345
                            primaryEnum.statement.tokens.name.range
346
                        )
347
                    }]
348
                });
349
            }
350
        }
351
        this.event.scope.addDiagnostics(diagnostics);
2,366✔
352
    }
353

354
    /**
355
     * Validate every function call to `CreateObject`.
356
     * Ideally we would create better type checking/handling for this, but in the mean time, we know exactly
357
     * what these calls are supposed to look like, and this is a very common thing for brs devs to do, so just
358
     * do this manually for now.
359
     */
360
    protected validateCreateObjectCalls(file: BrsFile) {
361
        const diagnostics: BsDiagnostic[] = [];
2,392✔
362

363
        for (const call of file?.functionCalls ?? []) {
2,392!
364
            //skip non CreateObject function calls
365
            if (call.name?.toLowerCase() !== 'createobject' || !isLiteralExpression(call?.args[0]?.expression)) {
215!
366
                continue;
166✔
367
            }
368
            const firstParamToken = (call?.args[0]?.expression as any)?.token;
49!
369
            const firstParamStringValue = firstParamToken?.text?.replace(/"/g, '');
49!
370
            //if this is a `createObject('roSGNode'` call, only support known sg node types
371
            if (firstParamStringValue?.toLowerCase() === 'rosgnode' && isLiteralExpression(call?.args[1]?.expression)) {
49!
372
                const componentName: Token = (call?.args[1]?.expression as any)?.token;
16!
373
                //don't validate any components with a colon in their name (probably component libraries, but regular components can have them too).
374
                if (componentName?.text?.includes(':')) {
16!
375
                    continue;
3✔
376
                }
377
                //add diagnostic for unknown components
378
                const unquotedComponentName = componentName?.text?.replace(/"/g, '');
13!
379
                if (unquotedComponentName && !platformNodeNames.has(unquotedComponentName.toLowerCase()) && !this.event.program.getComponent(unquotedComponentName)) {
13✔
380
                    this.addDiagnosticOnce({
5✔
381
                        file: file as BscFile,
382
                        ...DiagnosticMessages.unknownRoSGNode(unquotedComponentName),
383
                        range: componentName.range
384
                    });
385
                } else if (call?.args.length !== 2) {
8!
386
                    // roSgNode should only ever have 2 args in `createObject`
387
                    this.addDiagnosticOnce({
1✔
388
                        file: file as BscFile,
389
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, [2], call?.args.length),
3!
390
                        range: call.range
391
                    });
392
                }
393
            } else if (!platformComponentNames.has(firstParamStringValue.toLowerCase())) {
33✔
394
                this.addDiagnosticOnce({
9✔
395
                    file: file as BscFile,
396
                    ...DiagnosticMessages.unknownBrightScriptComponent(firstParamStringValue),
397
                    range: firstParamToken.range
398
                });
399
            } else {
400
                // This is valid brightscript component
401
                // Test for invalid arg counts
402
                const brightScriptComponent: BRSComponentData = components[firstParamStringValue.toLowerCase()];
24✔
403
                // Valid arg counts for createObject are 1+ number of args for constructor
404
                let validArgCounts = brightScriptComponent.constructors.map(cnstr => cnstr.params.length + 1);
27✔
405
                if (validArgCounts.length === 0) {
24✔
406
                    // no constructors for this component, so createObject only takes 1 arg
407
                    validArgCounts = [1];
4✔
408
                }
409
                if (!validArgCounts.includes(call?.args.length)) {
24!
410
                    // Incorrect number of arguments included in `createObject()`
411
                    this.addDiagnosticOnce({
5✔
412
                        file: file as BscFile,
413
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, validArgCounts, call?.args.length),
15!
414
                        range: call.range
415
                    });
416
                }
417

418
                // Test for deprecation
419
                if (brightScriptComponent.isDeprecated) {
24!
420
                    this.addDiagnosticOnce({
×
421
                        file: file as BscFile,
422
                        ...DiagnosticMessages.deprecatedBrightScriptComponent(firstParamStringValue, brightScriptComponent.deprecatedDescription),
423
                        range: call.range
424
                    });
425
                }
426
            }
427
        }
428
        this.event.scope.addDiagnostics(diagnostics);
2,392✔
429
    }
430

431
    /**
432
     * Adds a diagnostic to the first scope for this key. Prevents duplicate diagnostics
433
     * for diagnostics where scope isn't important. (i.e. CreateObject validations)
434
     */
435
    private addDiagnosticOnce(diagnostic: BsDiagnostic) {
436
        this.onceCache.getOrAdd(`${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
20✔
437
            this.event.scope.addDiagnostics([diagnostic]);
16✔
438
            return true;
16✔
439
        });
440
    }
441
    private onceCache = new Cache<string, boolean>();
1,448✔
442

443
    private addDiagnostic(diagnostic: BsDiagnostic) {
444
        this.event.scope.addDiagnostics([diagnostic]);
105✔
445
    }
446

447
    /**
448
     * Add a diagnostic (to the first scope) that will have `relatedInformation` for each affected scope
449
     */
450
    private addMultiScopeDiagnostic(diagnostic: BsDiagnostic, message = 'Not defined in scope') {
105✔
451
        diagnostic = this.multiScopeCache.getOrAdd(`${diagnostic.file?.srcPath}-${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
113!
452
            if (!diagnostic.relatedInformation) {
105✔
453
                diagnostic.relatedInformation = [];
97✔
454
            }
455
            this.addDiagnostic(diagnostic);
105✔
456
            return diagnostic;
105✔
457
        });
458
        const info = {
113✔
459
            message: `${message} '${this.event.scope.name}'`
460
        } as DiagnosticRelatedInformation;
461
        if (isXmlScope(this.event.scope) && this.event.scope.xmlFile?.srcPath) {
113!
462
            info.location = util.createLocation(
20✔
463
                URI.file(this.event.scope.xmlFile.srcPath).toString(),
464
                this.event.scope?.xmlFile?.ast?.component?.getAttribute('name')?.value.range ?? util.createRange(0, 0, 0, 10)
360!
465
            );
466
        } else {
467
            info.location = util.createLocation(
93✔
468
                URI.file(diagnostic.file.srcPath).toString(),
469
                diagnostic.range
470
            );
471
        }
472
        diagnostic.relatedInformation.push(info);
113✔
473
    }
474

475
    private multiScopeCache = new Cache<string, BsDiagnostic>();
1,448✔
476
}
477

478
interface ExpressionInfo {
479
    parts: Readonly<[VariableExpression, ...DottedGetExpression[]]>;
480
    expression: Readonly<Expression>;
481
    /**
482
     * The full namespace name that encloses this expression
483
     */
484
    enclosingNamespaceNameLower?: string;
485
}
486
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
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