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

rokucommunity / brighterscript / #15427

24 Mar 2026 04:58PM UTC coverage: 88.856% (-0.1%) from 88.993%
#15427

push

web-flow
Merge 9221958c5 into 0c894b16d

7940 of 9425 branches covered (84.24%)

Branch coverage included in aggregate %.

50 of 67 new or added lines in 4 files covered. (74.63%)

1 existing line in 1 file now uncovered.

10183 of 10971 relevant lines covered (92.82%)

1946.79 hits per line

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

82.32
/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,344✔
40
        this.walkFiles();
2,344✔
41
        this.detectDuplicateEnums();
2,344✔
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,344✔
52
            if (isBrsFile(file)) {
2,557✔
53
                this.iterateFileExpressions(file);
2,370✔
54
                this.validateCreateObjectCalls(file);
2,370✔
55
                this.validateComputedAAKeys(file);
2,370✔
56
            }
57
        });
58
    }
59

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

123
    /**
124
     * Recursively resolve a const/enum reference to determine if its ultimate value is a string.
125
     * Returns true if resolvable to a string, false if resolvable to a non-string, and true (permissive)
126
     * if it cannot be resolved (to avoid false positives).
127
     */
128
    private constResolvesToString(value: Expression, enclosingNamespace: string, scope: Scope, visited = new Set<string>()): boolean {
2✔
129
        if (isLiteralExpression(value)) {
2!
130
            return value.token.kind === TokenKind.StringLiteral;
2✔
131
        }
NEW
132
        const parts = util.getDottedGetPath(value);
×
NEW
133
        if (parts.length === 0) {
×
134
            // Cannot resolve — be permissive to avoid false positives
NEW
135
            return true;
×
136
        }
NEW
137
        const entityName = parts.map(p => p.name.text.toLowerCase()).join('.');
×
NEW
138
        if (visited.has(entityName)) {
×
NEW
139
            return true; // circular — be permissive
×
140
        }
NEW
141
        visited.add(entityName);
×
NEW
142
        const constLink = scope.getConstFileLink(entityName, enclosingNamespace);
×
NEW
143
        if (constLink) {
×
NEW
144
            return this.constResolvesToString(constLink.item.value, enclosingNamespace, scope, visited);
×
145
        }
NEW
146
        const memberLink = scope.getEnumMemberFileLink(entityName, enclosingNamespace);
×
NEW
147
        if (memberLink) {
×
NEW
148
            return memberLink.item.getValue()?.startsWith('"') ?? false;
×
149
        }
NEW
150
        return true; // unresolvable — be permissive
×
151
    }
152

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

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

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

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

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

217
            const enumStatement = scope.getEnum(firstNamespacePartLower, info.enclosingNamespaceNameLower);
773✔
218

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

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

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

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

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

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

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

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

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

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

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

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

448
    private addDiagnostic(diagnostic: BsDiagnostic) {
449
        this.event.scope.addDiagnostics([diagnostic]);
97✔
450
    }
451

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

480
    private multiScopeCache = new Cache<string, BsDiagnostic>();
1,436✔
481
}
482

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