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

rokucommunity / brighterscript / #15436

24 Mar 2026 07:13PM UTC coverage: 88.84% (-0.2%) from 88.993%
#15436

push

web-flow
Merge c62f9e56e into 0c894b16d

7949 of 9436 branches covered (84.24%)

Branch coverage included in aggregate %.

57 of 77 new or added lines in 5 files covered. (74.03%)

2 existing lines in 2 files now uncovered.

10185 of 10976 relevant lines covered (92.79%)

1947.36 hits per line

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

82.17
/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,346✔
40
        this.walkFiles();
2,346✔
41
        this.detectDuplicateEnums();
2,346✔
42
    }
43

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

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

60
    private validateComputedAAKeys(file: BrsFile) {
61
        const { scope } = this.event;
2,372✔
62
        file.ast.walk(createVisitor({
2,372✔
63
            AAIndexedMemberExpression: (member) => {
64
                // Direct string literal (e.g. ["my-key"]) is valid
65
                if (isLiteralExpression(member.key)) {
11✔
66
                    if (member.key.token.kind !== TokenKind.StringLiteral) {
2✔
67
                        this.addMultiScopeDiagnostic({
1✔
68
                            file: file,
69
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
70
                            range: member.key.range
71
                        });
72
                    }
73
                    return;
2✔
74
                }
75
                const parts = util.getDottedGetPath(member.key);
9✔
76
                if (parts.length === 0) {
9!
NEW
77
                    this.addMultiScopeDiagnostic({
×
78
                        file: file,
79
                        ...DiagnosticMessages.computedPropertyKeyMustBeConstantExpression(),
80
                        range: member.key.range
81
                    });
NEW
82
                    return;
×
83
                }
84
                const enclosingNamespace = member.key.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript)?.toLowerCase();
9!
85
                const entityName = parts.map(p => p.name.text.toLowerCase()).join('.');
16✔
86
                // Check enum member
87
                const memberLink = scope.getEnumMemberFileLink(entityName, enclosingNamespace);
9✔
88
                if (memberLink) {
9✔
89
                    const value = memberLink.item.getValue();
6✔
90
                    if (!value?.startsWith('"')) {
6!
91
                        this.addMultiScopeDiagnostic({
1✔
92
                            file: file,
93
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
94
                            range: member.key.range
95
                        });
96
                    }
97
                    return;
6✔
98
                }
99
                // Check const — follow the chain to find the root literal type
100
                const constLink = scope.getConstFileLink(entityName, enclosingNamespace);
3✔
101
                if (constLink) {
3✔
102
                    if (!this.constResolvesToString(constLink.item.value, enclosingNamespace, scope)) {
2✔
103
                        this.addMultiScopeDiagnostic({
1✔
104
                            file: file,
105
                            ...DiagnosticMessages.computedAAKeyMustBeStringExpression(),
106
                            range: member.key.range
107
                        });
108
                    }
109
                    return;
2✔
110
                }
111
                this.addMultiScopeDiagnostic({
1✔
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 if resolvable to a string, false if resolvable to a non-string, and true (permissive)
123
     * if it cannot be resolved (to avoid false positives).
124
     */
125
    private constResolvesToString(value: Expression, enclosingNamespace: string, scope: Scope, visited = new Set<string>()): boolean {
2✔
126
        if (isLiteralExpression(value)) {
2!
127
            return value.token.kind === TokenKind.StringLiteral;
2✔
128
        }
NEW
129
        const parts = util.getDottedGetPath(value);
×
NEW
130
        if (parts.length === 0) {
×
131
            // Cannot resolve — be permissive to avoid false positives
NEW
132
            return true;
×
133
        }
NEW
134
        const entityName = parts.map(p => p.name.text.toLowerCase()).join('.');
×
NEW
135
        if (visited.has(entityName)) {
×
NEW
136
            return true; // circular — be permissive
×
137
        }
NEW
138
        visited.add(entityName);
×
NEW
139
        const constLink = scope.getConstFileLink(entityName, enclosingNamespace);
×
NEW
140
        if (constLink) {
×
NEW
141
            return this.constResolvesToString(constLink.item.value, enclosingNamespace, scope, visited);
×
142
        }
NEW
143
        const memberLink = scope.getEnumMemberFileLink(entityName, enclosingNamespace);
×
NEW
144
        if (memberLink) {
×
NEW
145
            return memberLink.item.getValue()?.startsWith('"') ?? false;
×
146
        }
NEW
147
        return true; // unresolvable — be permissive
×
148
    }
149

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

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

171
                if (parts.length > 0) {
2,651✔
172
                    result.push({
817✔
173
                        parts: parts,
174
                        expression: expression,
175
                        enclosingNamespaceNameLower: expression.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript)?.toLowerCase()
4,902✔
176
                    });
177
                }
178
            }
179
            return result as unknown as Readonly<ExpressionInfo>[];
2,294✔
180
        });
181

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

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

214
            const enumStatement = scope.getEnum(firstNamespacePartLower, info.enclosingNamespaceNameLower);
773✔
215

216
            //if this isn't a namespace, skip it
217
            if (!namespaceContainer && !enumStatement) {
773✔
218
                continue;
616✔
219
            }
220

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

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

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

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

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

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

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

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

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

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

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

445
    private addDiagnostic(diagnostic: BsDiagnostic) {
446
        this.event.scope.addDiagnostics([diagnostic]);
97✔
447
    }
448

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

477
    private multiScopeCache = new Cache<string, BsDiagnostic>();
1,438✔
478
}
479

480
interface ExpressionInfo {
481
    parts: Readonly<[VariableExpression, ...DottedGetExpression[]]>;
482
    expression: Readonly<Expression>;
483
    /**
484
     * The full namespace name that encloses this expression
485
     */
486
    enclosingNamespaceNameLower?: string;
487
}
488
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