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

rokucommunity / brighterscript / #13342

25 Nov 2024 08:44PM UTC coverage: 89.053% (+2.2%) from 86.874%
#13342

push

web-flow
Merge 961502182 into c5674f5d8

7359 of 8712 branches covered (84.47%)

Branch coverage included in aggregate %.

55 of 64 new or added lines in 9 files covered. (85.94%)

544 existing lines in 54 files now uncovered.

9724 of 10471 relevant lines covered (92.87%)

1825.46 hits per line

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

87.5
/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 type { Scope } from '../../Scope';
13
import type { DiagnosticRelatedInformation } from 'vscode-languageserver';
14
import type { Expression } from '../../parser/AstNode';
15
import type { VariableExpression, DottedGetExpression } from '../../parser/Expression';
16
import { ParseMode } from '../../parser/Parser';
1✔
17

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

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

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

36
    public processEvent(event: OnScopeValidateEvent) {
37
        this.event = event;
2,118✔
38
        this.walkFiles();
2,118✔
39
        this.detectDuplicateEnums();
2,118✔
40
    }
41

42
    public reset() {
43
        this.event = undefined;
764✔
44
        this.onceCache.clear();
764✔
45
        this.multiScopeCache.clear();
764✔
46
    }
47

48
    private walkFiles() {
49
        this.event.scope.enumerateOwnFiles((file) => {
2,118✔
50
            if (isBrsFile(file)) {
2,309✔
51
                this.iterateFileExpressions(file);
2,148✔
52
                this.validateCreateObjectCalls(file);
2,148✔
53
            }
54
        });
55
    }
56

57
    private expressionsByFile = new Cache<BrsFile, Readonly<ExpressionInfo>[]>();
1,321✔
58
    private iterateFileExpressions(file: BrsFile) {
59
        const { scope } = this.event;
2,148✔
60
        //build an expression collection ONCE per file
61
        const expressionInfos = this.expressionsByFile.getOrAdd(file, () => {
2,148✔
62
            const result: DeepWriteable<ExpressionInfo[]> = [];
2,083✔
63
            const expressions = [
2,083✔
64
                ...file.parser.references.expressions,
65
                //all class "extends <whatever>" expressions
66
                ...file.parser.references.classStatements.map(x => x.parentClassName?.expression),
185✔
67
                //all interface "extends <whatever>" expressions
68
                ...file.parser.references.interfaceStatements.map(x => x.parentInterfaceName?.expression)
30✔
69
            ];
70
            for (let expression of expressions) {
2,083✔
71
                if (!expression) {
2,570✔
72
                    continue;
152✔
73
                }
74

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

78
                if (parts.length > 0) {
2,418✔
79
                    result.push({
715✔
80
                        parts: parts,
81
                        expression: expression,
82
                        enclosingNamespaceNameLower: expression.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript)?.toLowerCase()
4,290✔
83
                    });
84
                }
85
            }
86
            return result as unknown as Readonly<ExpressionInfo>[];
2,083✔
87
        });
88

89
        outer:
2,148✔
90
        for (const info of expressionInfos) {
2,148✔
91
            const symbolTable = info.expression.getSymbolTable();
734✔
92
            const firstPart = info.parts[0];
734✔
93
            const firstNamespacePart = info.parts[0].name.text;
734✔
94
            const firstNamespacePartLower = firstNamespacePart?.toLowerCase();
734!
95
            //get the namespace container (accounting for namespace-relative as well)
96
            const namespaceContainer = scope.getNamespace(firstNamespacePartLower, info.enclosingNamespaceNameLower);
734✔
97

98
            //flag all unknown left-most variables
99
            if (
734✔
100
                !symbolTable?.hasSymbol(firstPart.name?.text) &&
5,209!
101
                !namespaceContainer
102
            ) {
103
                //flag functions differently than all other variables
104
                if (isCallExpression(firstPart.parent) && firstPart.parent.callee === firstPart) {
69✔
105
                    this.addMultiScopeDiagnostic({
14✔
106
                        file: file as BscFile,
107
                        ...DiagnosticMessages.cannotFindFunction(firstPart.name?.text),
42!
108
                        range: firstPart.name.range
109
                    });
110
                } else {
111
                    this.addMultiScopeDiagnostic({
55✔
112
                        file: file as BscFile,
113
                        ...DiagnosticMessages.cannotFindName(firstPart.name?.text),
165!
114
                        range: firstPart.name.range
115
                    });
116
                }
117
                //skip to the next expression
118
                continue;
69✔
119
            }
120

121
            const enumStatement = scope.getEnum(firstNamespacePartLower, info.enclosingNamespaceNameLower);
665✔
122

123
            //if this isn't a namespace, skip it
124
            if (!namespaceContainer && !enumStatement) {
665✔
125
                continue;
541✔
126
            }
127

128
            //catch unknown namespace items
129
            let entityName = firstNamespacePart;
124✔
130
            let entityNameLower = firstNamespacePart.toLowerCase();
124✔
131
            for (let i = 1; i < info.parts.length; i++) {
124✔
132
                const part = info.parts[i];
162✔
133
                entityName += '.' + part.name.text;
162✔
134
                entityNameLower += '.' + part.name.text.toLowerCase();
162✔
135

136
                //if this is an enum member, stop validating here to prevent errors further down the chain
137
                if (scope.getEnumMemberFileLink(entityName, info.enclosingNamespaceNameLower)) {
162✔
138
                    break;
60✔
139
                }
140

141
                if (
102✔
142
                    !scope.getEnumMap().has(entityNameLower) &&
379✔
143
                    !scope.getClassMap().has(entityNameLower) &&
144
                    !scope.getConstMap().has(entityNameLower) &&
145
                    !scope.getCallableByName(entityNameLower) &&
146
                    !scope.getNamespace(entityNameLower, info.enclosingNamespaceNameLower)
147
                ) {
148
                    //if this looks like an enum, provide a nicer error message
149
                    const theEnum = this.getEnum(scope, entityNameLower)?.item;
15✔
150
                    if (theEnum) {
15✔
151
                        this.addMultiScopeDiagnostic({
5✔
152
                            file: file,
153
                            ...DiagnosticMessages.unknownEnumValue(part.name.text?.split('.').pop(), theEnum.fullName),
15!
154
                            range: part.name.range,
155
                            relatedInformation: [{
156
                                message: 'Enum declared here',
157
                                location: util.createLocation(
158
                                    URI.file(file.srcPath).toString(),
159
                                    theEnum.tokens.name.range
160
                                )
161
                            }]
162
                        });
163
                    } else {
164
                        //flag functions differently than all other variables
165
                        if (isCallExpression(firstPart.parent) && firstPart.parent.callee === firstPart) {
10!
UNCOV
166
                            this.addMultiScopeDiagnostic({
×
167
                                ...DiagnosticMessages.cannotFindFunction(part.name.text, entityName),
168
                                range: part.name.range,
169
                                file: file
170
                            });
171
                        } else {
172
                            this.addMultiScopeDiagnostic({
10✔
173
                                ...DiagnosticMessages.cannotFindName(part.name.text, entityName),
174
                                range: part.name.range,
175
                                file: file
176
                            });
177
                        }
178
                    }
179
                    //no need to add another diagnostic for future unknown items
180
                    continue outer;
15✔
181
                }
182
            }
183
            //if the full expression is a namespace path, this is an illegal statement because namespaces don't exist at runtme
184
            if (scope.getNamespace(entityNameLower, info.enclosingNamespaceNameLower)) {
109✔
185
                this.addMultiScopeDiagnostic({
8✔
186
                    ...DiagnosticMessages.itemCannotBeUsedAsVariable('namespace'),
187
                    range: info.expression.range,
188
                    file: file
189
                }, 'When used in scope');
190
            }
191
        }
192
    }
193

194
    /**
195
     * Given a string optionally separated by dots, find an enum related to it.
196
     * For example, all of these would return the enum: `SomeNamespace.SomeEnum.SomeMember`, SomeEnum.SomeMember, `SomeEnum`
197
     */
198
    private getEnum(scope: Scope, name: string) {
199
        //look for the enum directly
200
        let result = scope.getEnumMap().get(name);
15✔
201

202
        //assume we've been given the enum.member syntax, so pop the member and try again
203
        if (!result) {
15!
204
            const parts = name.split('.');
15✔
205
            parts.pop();
15✔
206
            result = scope.getEnumMap().get(parts.join('.'));
15✔
207
        }
208
        return result;
15✔
209
    }
210

211
    /**
212
     * Flag duplicate enums
213
     */
214
    private detectDuplicateEnums() {
215
        const diagnostics: BsDiagnostic[] = [];
2,118✔
216
        const enumLocationsByName = new Cache<string, Array<{ file: BrsFile; statement: EnumStatement }>>();
2,118✔
217
        this.event.scope.enumerateBrsFiles((file) => {
2,118✔
218
            for (const enumStatement of file.parser.references.enumStatements) {
2,158✔
219
                const fullName = enumStatement.fullName;
74✔
220
                const nameLower = fullName?.toLowerCase();
74!
221
                if (nameLower?.length > 0) {
74!
222
                    enumLocationsByName.getOrAdd(nameLower, () => []).push({
74✔
223
                        file: file,
224
                        statement: enumStatement
225
                    });
226
                }
227
            }
228
        });
229

230
        //now that we've collected all enum declarations, flag duplicates
231
        for (const enumLocations of enumLocationsByName.values()) {
2,118✔
232
            //sort by srcPath to keep the primary enum location consistent
233
            enumLocations.sort((a, b) => {
72✔
234
                const pathA = a.file?.srcPath;
2!
235
                const pathB = b.file?.srcPath;
2!
236
                if (pathA < pathB) {
2✔
237
                    return -1;
1✔
238
                } else if (pathA > pathB) {
1!
UNCOV
239
                    return 1;
×
240
                }
241
                return 0;
1✔
242
            });
243
            const primaryEnum = enumLocations.shift();
72✔
244
            const fullName = primaryEnum.statement.fullName;
72✔
245
            for (const duplicateEnumInfo of enumLocations) {
72✔
246
                diagnostics.push({
2✔
247
                    ...DiagnosticMessages.duplicateEnumDeclaration(this.event.scope.name, fullName),
248
                    file: duplicateEnumInfo.file,
249
                    range: duplicateEnumInfo.statement.tokens.name.range,
250
                    relatedInformation: [{
251
                        message: 'Enum declared here',
252
                        location: util.createLocation(
253
                            URI.file(primaryEnum.file.srcPath).toString(),
254
                            primaryEnum.statement.tokens.name.range
255
                        )
256
                    }]
257
                });
258
            }
259
        }
260
        this.event.scope.addDiagnostics(diagnostics);
2,118✔
261
    }
262

263
    /**
264
     * Validate every function call to `CreateObject`.
265
     * Ideally we would create better type checking/handling for this, but in the mean time, we know exactly
266
     * what these calls are supposed to look like, and this is a very common thing for brs devs to do, so just
267
     * do this manually for now.
268
     */
269
    protected validateCreateObjectCalls(file: BrsFile) {
270
        const diagnostics: BsDiagnostic[] = [];
2,148✔
271

272
        for (const call of file?.functionCalls ?? []) {
2,148!
273
            //skip non CreateObject function calls
274
            if (call.name?.toLowerCase() !== 'createobject' || !isLiteralExpression(call?.args[0]?.expression)) {
182!
275
                continue;
137✔
276
            }
277
            const firstParamToken = (call?.args[0]?.expression as any)?.token;
45!
278
            const firstParamStringValue = firstParamToken?.text?.replace(/"/g, '');
45!
279
            //if this is a `createObject('roSGNode'` call, only support known sg node types
280
            if (firstParamStringValue?.toLowerCase() === 'rosgnode' && isLiteralExpression(call?.args[1]?.expression)) {
45!
281
                const componentName: Token = (call?.args[1]?.expression as any)?.token;
16!
282
                //don't validate any components with a colon in their name (probably component libraries, but regular components can have them too).
283
                if (componentName?.text?.includes(':')) {
16!
284
                    continue;
3✔
285
                }
286
                //add diagnostic for unknown components
287
                const unquotedComponentName = componentName?.text?.replace(/"/g, '');
13!
288
                if (unquotedComponentName && !platformNodeNames.has(unquotedComponentName.toLowerCase()) && !this.event.program.getComponent(unquotedComponentName)) {
13✔
289
                    this.addDiagnosticOnce({
5✔
290
                        file: file as BscFile,
291
                        ...DiagnosticMessages.unknownRoSGNode(unquotedComponentName),
292
                        range: componentName.range
293
                    });
294
                } else if (call?.args.length !== 2) {
8!
295
                    // roSgNode should only ever have 2 args in `createObject`
296
                    this.addDiagnosticOnce({
1✔
297
                        file: file as BscFile,
298
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, [2], call?.args.length),
3!
299
                        range: call.range
300
                    });
301
                }
302
            } else if (!platformComponentNames.has(firstParamStringValue.toLowerCase())) {
29✔
303
                this.addDiagnosticOnce({
9✔
304
                    file: file as BscFile,
305
                    ...DiagnosticMessages.unknownBrightScriptComponent(firstParamStringValue),
306
                    range: firstParamToken.range
307
                });
308
            } else {
309
                // This is valid brightscript component
310
                // Test for invalid arg counts
311
                const brightScriptComponent: BRSComponentData = components[firstParamStringValue.toLowerCase()];
20✔
312
                // Valid arg counts for createObject are 1+ number of args for constructor
313
                let validArgCounts = brightScriptComponent.constructors.map(cnstr => cnstr.params.length + 1);
25✔
314
                if (validArgCounts.length === 0) {
20✔
315
                    // no constructors for this component, so createObject only takes 1 arg
316
                    validArgCounts = [1];
2✔
317
                }
318
                if (!validArgCounts.includes(call?.args.length)) {
20!
319
                    // Incorrect number of arguments included in `createObject()`
320
                    this.addDiagnosticOnce({
5✔
321
                        file: file as BscFile,
322
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, validArgCounts, call?.args.length),
15!
323
                        range: call.range
324
                    });
325
                }
326

327
                // Test for deprecation
328
                if (brightScriptComponent.isDeprecated) {
20!
UNCOV
329
                    this.addDiagnosticOnce({
×
330
                        file: file as BscFile,
331
                        ...DiagnosticMessages.deprecatedBrightScriptComponent(firstParamStringValue, brightScriptComponent.deprecatedDescription),
332
                        range: call.range
333
                    });
334
                }
335
            }
336
        }
337
        this.event.scope.addDiagnostics(diagnostics);
2,148✔
338
    }
339

340
    /**
341
     * Adds a diagnostic to the first scope for this key. Prevents duplicate diagnostics
342
     * for diagnostics where scope isn't important. (i.e. CreateObject validations)
343
     */
344
    private addDiagnosticOnce(diagnostic: BsDiagnostic) {
345
        this.onceCache.getOrAdd(`${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
20✔
346
            this.event.scope.addDiagnostics([diagnostic]);
16✔
347
            return true;
16✔
348
        });
349
    }
350
    private onceCache = new Cache<string, boolean>();
1,321✔
351

352
    private addDiagnostic(diagnostic: BsDiagnostic) {
353
        this.event.scope.addDiagnostics([diagnostic]);
90✔
354
    }
355

356
    /**
357
     * Add a diagnostic (to the first scope) that will have `relatedInformation` for each affected scope
358
     */
359
    private addMultiScopeDiagnostic(diagnostic: BsDiagnostic, message = 'Not defined in scope') {
84✔
360
        diagnostic = this.multiScopeCache.getOrAdd(`${diagnostic.file?.srcPath}-${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
92!
361
            if (!diagnostic.relatedInformation) {
90✔
362
                diagnostic.relatedInformation = [];
85✔
363
            }
364
            this.addDiagnostic(diagnostic);
90✔
365
            return diagnostic;
90✔
366
        });
367
        const info = {
92✔
368
            message: `${message} '${this.event.scope.name}'`
369
        } as DiagnosticRelatedInformation;
370
        if (isXmlScope(this.event.scope) && this.event.scope.xmlFile?.srcPath) {
92!
371
            info.location = util.createLocation(
14✔
372
                URI.file(this.event.scope.xmlFile.srcPath).toString(),
373
                this.event.scope?.xmlFile?.ast?.component?.getAttribute('name')?.value.range ?? util.createRange(0, 0, 0, 10)
252!
374
            );
375
        } else {
376
            info.location = util.createLocation(
78✔
377
                URI.file(diagnostic.file.srcPath).toString(),
378
                diagnostic.range
379
            );
380
        }
381
        diagnostic.relatedInformation.push(info);
92✔
382
    }
383

384
    private multiScopeCache = new Cache<string, BsDiagnostic>();
1,321✔
385
}
386

387
interface ExpressionInfo {
388
    parts: Readonly<[VariableExpression, ...DottedGetExpression[]]>;
389
    expression: Readonly<Expression>;
390
    /**
391
     * The full namespace name that encloses this expression
392
     */
393
    enclosingNamespaceNameLower?: string;
394
}
395
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