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

rokucommunity / brighterscript / #15046

03 Oct 2022 01:55PM UTC coverage: 87.532% (-0.3%) from 87.808%
#15046

push

TwitchBronBron
0.59.0

5452 of 6706 branches covered (81.3%)

Branch coverage included in aggregate %.

8259 of 8958 relevant lines covered (92.2%)

1521.92 hits per line

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

89.19
/src/bscPlugin/validation/ScopeValidator.ts
1
import { URI } from 'vscode-uri';
1✔
2
import { isBrsFile, isLiteralExpression, 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 } 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

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

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

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

35
    public processEvent(event: OnScopeValidateEvent) {
36
        this.event = event;
1,533✔
37
        this.walkFiles();
1,533✔
38
        this.detectDuplicateEnums();
1,533✔
39
    }
40

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

47
    private walkFiles() {
48
        this.event.scope.enumerateOwnFiles((file) => {
1,533✔
49
            if (isBrsFile(file)) {
1,665✔
50
                this.iterateFileExpressions(file);
1,533✔
51
                this.validateCreateObjectCalls(file);
1,533✔
52
            }
53
        });
54
    }
55

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

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

77
                if (parts.length > 0) {
1,608✔
78
                    result.push({
549✔
79
                        parts: parts,
80
                        expression: expression
81
                    });
82
                }
83
            }
84
            return result as unknown as Readonly<ExpressionInfo>[];
1,484✔
85
        });
86

87
        outer:
1,533✔
88
        for (const info of expressionInfos) {
1,533✔
89
            const symbolTable = info.expression.getSymbolTable();
568✔
90
            const firstPart = info.parts[0];
568✔
91
            //flag all unknown left-most variables
92
            if (!symbolTable.hasSymbol(firstPart.name?.text)) {
568!
93
                this.addMultiScopeDiagnostic({
54✔
94
                    file: file as BscFile,
95
                    ...DiagnosticMessages.cannotFindName(firstPart.name?.text),
162!
96
                    range: firstPart.name.range
97
                });
98
                //skip to the next expression
99
                continue;
54✔
100
            }
101

102
            const firstNamespacePart = info.parts[0].name.text;
514✔
103
            const firstNamespacePartLower = firstNamespacePart?.toLowerCase();
514!
104
            const namespaceContainer = scope.namespaceLookup.get(firstNamespacePartLower);
514✔
105
            const enumStatement = scope.getEnum(firstNamespacePartLower);
514✔
106
            //if this isn't a namespace, skip it
107
            if (!namespaceContainer && !enumStatement) {
514✔
108
                continue;
431✔
109
            }
110
            //catch unknown namespace items
111
            const processedNames: string[] = [firstNamespacePart];
83✔
112
            for (let i = 1; i < info.parts.length; i++) {
83✔
113
                const part = info.parts[i];
112✔
114
                processedNames.push(part.name.text);
112✔
115
                const entityName = processedNames.join('.');
112✔
116
                const entityNameLower = entityName.toLowerCase();
112✔
117

118
                //if this is an enum member, stop validating here to prevent errors further down the chain
119
                if (scope.getEnumMemberMap().has(entityNameLower)) {
112✔
120
                    break;
41✔
121
                }
122

123
                if (
71✔
124
                    !scope.getEnumMap().has(entityNameLower) &&
262✔
125
                    !scope.getClassMap().has(entityNameLower) &&
126
                    !scope.getConstMap().has(entityNameLower) &&
127
                    !scope.getCallableByName(entityNameLower) &&
128
                    !scope.namespaceLookup.has(entityNameLower)
129
                ) {
130
                    //if this looks like an enum, provide a nicer error message
131
                    const theEnum = this.getEnum(scope, entityNameLower)?.item;
13✔
132
                    if (theEnum) {
13✔
133
                        this.addMultiScopeDiagnostic({
5✔
134
                            file: file,
135
                            ...DiagnosticMessages.unknownEnumValue(part.name.text?.split('.').pop(), theEnum.fullName),
15!
136
                            range: part.name.range,
137
                            relatedInformation: [{
138
                                message: 'Enum declared here',
139
                                location: util.createLocation(
140
                                    URI.file(file.srcPath).toString(),
141
                                    theEnum.tokens.name.range
142
                                )
143
                            }]
144
                        });
145
                    } else {
146
                        this.addMultiScopeDiagnostic({
8✔
147
                            ...DiagnosticMessages.cannotFindName(part.name.text, entityName),
148
                            range: part.name.range,
149
                            file: file
150
                        });
151
                    }
152
                    //no need to add another diagnostic for future unknown items
153
                    continue outer;
13✔
154
                }
155
            }
156
        }
157
    }
158

159
    /**
160
     * Given a string optionally separated by dots, find an enum related to it.
161
     * For example, all of these would return the enum: `SomeNamespace.SomeEnum.SomeMember`, SomeEnum.SomeMember, `SomeEnum`
162
     */
163
    private getEnum(scope: Scope, name: string) {
164
        //look for the enum directly
165
        let result = scope.getEnumMap().get(name);
13✔
166

167
        //assume we've been given the enum.member syntax, so pop the member and try again
168
        if (!result) {
13!
169
            const parts = name.split('.');
13✔
170
            parts.pop();
13✔
171
            result = scope.getEnumMap().get(parts.join('.'));
13✔
172
        }
173
        return result;
13✔
174
    }
175

176
    /**
177
     * Flag duplicate enums
178
     */
179
    private detectDuplicateEnums() {
180
        const diagnostics: BsDiagnostic[] = [];
1,533✔
181
        const enumLocationsByName = new Cache<string, Array<{ file: BrsFile; statement: EnumStatement }>>();
1,533✔
182
        this.event.scope.enumerateBrsFiles((file) => {
1,533✔
183
            for (const enumStatement of file.parser.references.enumStatements) {
1,543✔
184
                const fullName = enumStatement.fullName;
47✔
185
                const nameLower = fullName?.toLowerCase();
47!
186
                if (nameLower?.length > 0) {
47!
187
                    enumLocationsByName.getOrAdd(nameLower, () => []).push({
47✔
188
                        file: file,
189
                        statement: enumStatement
190
                    });
191
                }
192
            }
193
        });
194

195
        //now that we've collected all enum declarations, flag duplicates
196
        for (const enumLocations of enumLocationsByName.values()) {
1,533✔
197
            //sort by srcPath to keep the primary enum location consistent
198
            enumLocations.sort((a, b) => {
45✔
199
                const pathA = a.file?.srcPath;
2!
200
                const pathB = b.file?.srcPath;
2!
201
                if (pathA < pathB) {
2✔
202
                    return -1;
1✔
203
                } else if (pathA > pathB) {
1!
204
                    return 1;
×
205
                }
206
                return 0;
1✔
207
            });
208
            const primaryEnum = enumLocations.shift();
45✔
209
            const fullName = primaryEnum.statement.fullName;
45✔
210
            for (const duplicateEnumInfo of enumLocations) {
45✔
211
                diagnostics.push({
2✔
212
                    ...DiagnosticMessages.duplicateEnumDeclaration(this.event.scope.name, fullName),
213
                    file: duplicateEnumInfo.file,
214
                    range: duplicateEnumInfo.statement.tokens.name.range,
215
                    relatedInformation: [{
216
                        message: 'Enum declared here',
217
                        location: util.createLocation(
218
                            URI.file(primaryEnum.file.srcPath).toString(),
219
                            primaryEnum.statement.tokens.name.range
220
                        )
221
                    }]
222
                });
223
            }
224
        }
225
        this.event.scope.addDiagnostics(diagnostics);
1,533✔
226
    }
227

228
    /**
229
     * Validate every function call to `CreateObject`.
230
     * Ideally we would create better type checking/handling for this, but in the mean time, we know exactly
231
     * what these calls are supposed to look like, and this is a very common thing for brs devs to do, so just
232
     * do this manually for now.
233
     */
234
    protected validateCreateObjectCalls(file: BrsFile) {
235
        const diagnostics: BsDiagnostic[] = [];
1,533✔
236

237
        for (const call of file.functionCalls) {
1,533✔
238
            //skip non CreateObject function calls
239
            if (call.name?.toLowerCase() !== 'createobject' || !isLiteralExpression(call?.args[0]?.expression)) {
152!
240
                continue;
107✔
241
            }
242
            const firstParamToken = (call?.args[0]?.expression as any)?.token;
45!
243
            const firstParamStringValue = firstParamToken?.text?.replace(/"/g, '');
45!
244
            //if this is a `createObject('roSGNode'` call, only support known sg node types
245
            if (firstParamStringValue?.toLowerCase() === 'rosgnode' && isLiteralExpression(call?.args[1]?.expression)) {
45!
246
                const componentName: Token = (call?.args[1]?.expression as any)?.token;
17!
247
                //don't validate any components with a colon in their name (probably component libraries, but regular components can have them too).
248
                if (componentName?.text?.includes(':')) {
17!
249
                    continue;
3✔
250
                }
251
                //add diagnostic for unknown components
252
                const unquotedComponentName = componentName?.text?.replace(/"/g, '');
14!
253
                if (unquotedComponentName && !platformNodeNames.has(unquotedComponentName.toLowerCase()) && !this.event.program.getComponent(unquotedComponentName)) {
14✔
254
                    this.addDiagnosticOnce({
5✔
255
                        file: file as BscFile,
256
                        ...DiagnosticMessages.unknownRoSGNode(unquotedComponentName),
257
                        range: componentName.range
258
                    });
259
                } else if (call?.args.length !== 2) {
9!
260
                    // roSgNode should only ever have 2 args in `createObject`
261
                    this.addDiagnosticOnce({
1✔
262
                        file: file as BscFile,
263
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, [2], call?.args.length),
3!
264
                        range: call.range
265
                    });
266
                }
267
            } else if (!platformComponentNames.has(firstParamStringValue.toLowerCase())) {
28✔
268
                this.addDiagnosticOnce({
6✔
269
                    file: file as BscFile,
270
                    ...DiagnosticMessages.unknownBrightScriptComponent(firstParamStringValue),
271
                    range: firstParamToken.range
272
                });
273
            } else {
274
                // This is valid brightscript component
275
                // Test for invalid arg counts
276
                const brightScriptComponent: BRSComponentData = components[firstParamStringValue.toLowerCase()];
22✔
277
                // Valid arg counts for createObject are 1+ number of args for constructor
278
                let validArgCounts = brightScriptComponent.constructors.map(cnstr => cnstr.params.length + 1);
31✔
279
                if (validArgCounts.length === 0) {
22✔
280
                    // no constructors for this component, so createObject only takes 1 arg
281
                    validArgCounts = [1];
1✔
282
                }
283
                if (!validArgCounts.includes(call?.args.length)) {
22!
284
                    // Incorrect number of arguments included in `createObject()`
285
                    this.addDiagnosticOnce({
5✔
286
                        file: file as BscFile,
287
                        ...DiagnosticMessages.mismatchCreateObjectArgumentCount(firstParamStringValue, validArgCounts, call?.args.length),
15!
288
                        range: call.range
289
                    });
290
                }
291

292
                // Test for deprecation
293
                if (brightScriptComponent.isDeprecated) {
22✔
294
                    this.addDiagnosticOnce({
3✔
295
                        file: file as BscFile,
296
                        ...DiagnosticMessages.deprecatedBrightScriptComponent(firstParamStringValue, brightScriptComponent.deprecatedDescription),
297
                        range: call.range
298
                    });
299
                }
300
            }
301
        }
302
        this.event.scope.addDiagnostics(diagnostics);
1,533✔
303
    }
304

305
    /**
306
     * Adds a diagnostic to the first scope for this key. Prevents duplicate diagnostics
307
     * for diagnostics where scope isn't important. (i.e. CreateObject validations)
308
     */
309
    private addDiagnosticOnce(diagnostic: BsDiagnostic) {
310
        this.onceCache.getOrAdd(`${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
20✔
311
            this.event.scope.addDiagnostics([diagnostic]);
16✔
312
            return true;
16✔
313
        });
314
    }
315
    private onceCache = new Cache<string, boolean>();
940✔
316

317
    private addDiagnostic(diagnostic: BsDiagnostic) {
318
        this.event.scope.addDiagnostics([diagnostic]);
65✔
319
    }
320

321
    /**
322
     * Add a diagnostic (to the first scope) that will have `relatedInformation` for each affected scope
323
     */
324
    private addMultiScopeDiagnostic(diagnostic: BsDiagnostic, message = 'Not defined in scope') {
67✔
325
        diagnostic = this.multiScopeCache.getOrAdd(`${diagnostic.file?.srcPath}-${diagnostic.code}-${diagnostic.message}-${util.rangeToString(diagnostic.range)}`, () => {
67!
326
            if (!diagnostic.relatedInformation) {
65✔
327
                diagnostic.relatedInformation = [];
60✔
328
            }
329
            this.addDiagnostic(diagnostic);
65✔
330
            return diagnostic;
65✔
331
        });
332
        const info = {
67✔
333
            message: `${message} '${this.event.scope.name}'`
334
        } as DiagnosticRelatedInformation;
335
        if (isXmlScope(this.event.scope) && this.event.scope.xmlFile?.srcPath) {
67!
336
            info.location = util.createLocation(
13✔
337
                URI.file(this.event.scope.xmlFile.srcPath).toString(),
338
                util.createRange(0, 0, 0, 10)
339
            );
340
        } else {
341
            info.location = util.createLocation(
54✔
342
                URI.file(diagnostic.file.srcPath).toString(),
343
                diagnostic.range
344
            );
345
        }
346
        diagnostic.relatedInformation.push(info);
67✔
347
    }
348

349
    private multiScopeCache = new Cache<string, BsDiagnostic>();
940✔
350
}
351

352
interface ExpressionInfo {
353
    parts: Readonly<[VariableExpression, ...DottedGetExpression[]]>;
354
    expression: Readonly<Expression>;
355
}
356
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