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

rokucommunity / brighterscript / #12837

24 Jul 2024 05:52PM UTC coverage: 87.936% (+2.3%) from 85.65%
#12837

push

TwitchBronBron
0.67.4

6069 of 7376 branches covered (82.28%)

Branch coverage included in aggregate %.

8793 of 9525 relevant lines covered (92.31%)

1741.63 hits per line

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

94.25
/src/validators/ClassValidator.ts
1
import type { Scope } from '../Scope';
2
import { DiagnosticMessages } from '../DiagnosticMessages';
1✔
3
import type { CallExpression } from '../parser/Expression';
4
import { ParseMode } from '../parser/Parser';
1✔
5
import type { ClassStatement, MethodStatement, NamespaceStatement } from '../parser/Statement';
6
import { CancellationTokenSource } from 'vscode-languageserver';
1✔
7
import { URI } from 'vscode-uri';
1✔
8
import util from '../util';
1✔
9
import { isCallExpression, isFieldStatement, isMethodStatement, isCustomType, isNamespaceStatement } from '../astUtils/reflection';
1✔
10
import type { BscFile, BsDiagnostic } from '../interfaces';
11
import { createVisitor, WalkMode } from '../astUtils/visitors';
1✔
12
import type { BrsFile } from '../files/BrsFile';
13
import { TokenKind } from '../lexer/TokenKind';
1✔
14
import { DynamicType } from '../types/DynamicType';
1✔
15
import type { BscType } from '../types/BscType';
16

17
export class BsClassValidator {
1✔
18
    private scope: Scope;
19
    public diagnostics: BsDiagnostic[];
20
    /**
21
     * The key is the namespace-prefixed class name. (i.e. `NameA.NameB.SomeClass` or `CoolClass`)
22
     */
23
    private classes: Map<string, AugmentedClassStatement> = new Map();
1,850✔
24

25
    public constructor(scope: Scope) {
26
        this.scope = scope;
1,850✔
27
        this.diagnostics = [];
1,850✔
28
    }
29

30
    public validate() {
31
        this.findClasses();
1,850✔
32
        this.findNamespaceNonNamespaceCollisions();
1,850✔
33
        this.linkClassesWithParents();
1,850✔
34
        this.detectCircularReferences();
1,850✔
35
        this.validateMemberCollisions();
1,850✔
36
        this.verifyChildConstructor();
1,850✔
37
        this.verifyNewExpressions();
1,850✔
38
        this.validateFieldTypes();
1,850✔
39

40
        this.cleanUp();
1,850✔
41
    }
42

43
    /**
44
     * Given a class name optionally prefixed with a namespace name, find the class that matches
45
     */
46
    private getClassByName(className: string, namespaceName?: string) {
47
        let fullName = util.getFullyQualifiedClassName(className, namespaceName);
39✔
48
        let cls = this.classes.get(fullName.toLowerCase());
39✔
49
        //if we couldn't find the class by its full namespaced name, look for a global class with that name
50
        if (!cls) {
39✔
51
            cls = this.classes.get(className.toLowerCase());
13✔
52
        }
53
        return cls;
39✔
54
    }
55

56

57
    /**
58
     * Find all "new" statements in the program,
59
     * and make sure we can find a class with that name
60
     */
61
    private verifyNewExpressions() {
62
        this.scope.enumerateBrsFiles((file) => {
1,850✔
63
            let newExpressions = file.parser.references.newExpressions;
1,857✔
64
            for (let newExpression of newExpressions) {
1,857✔
65
                let className = newExpression.className.getName(ParseMode.BrighterScript);
32✔
66
                const namespaceName = newExpression.findAncestor<NamespaceStatement>(isNamespaceStatement)?.getName(ParseMode.BrighterScript);
32✔
67
                let newableClass = this.getClassByName(
32✔
68
                    className,
69
                    namespaceName
70
                );
71

72
                if (!newableClass) {
32✔
73
                    //try and find functions with this name.
74
                    let fullName = util.getFullyQualifiedClassName(className, namespaceName);
6✔
75
                    let callable = this.scope.getCallableByName(fullName);
6✔
76
                    //if we found a callable with this name, the user used a "new" keyword in front of a function. add error
77
                    if (callable) {
6✔
78
                        this.diagnostics.push({
1✔
79
                            ...DiagnosticMessages.expressionIsNotConstructable(callable.isSub ? 'sub' : 'function'),
1!
80
                            file: file,
81
                            range: newExpression.className.range
82
                        });
83

84
                    } else {
85
                        //could not find a class with this name (handled by ScopeValidator)
86
                    }
87
                }
88
            }
89
        });
90
    }
91

92
    private findNamespaceNonNamespaceCollisions() {
93
        for (const [className, classStatement] of this.classes) {
1,850✔
94
            //catch namespace class collision with global class
95
            let nonNamespaceClassName = util.getTextAfterFinalDot(className)?.toLowerCase();
183!
96
            let nonNamespaceClass = this.classes.get(nonNamespaceClassName!);
183✔
97
            const namespace = classStatement.findAncestor<NamespaceStatement>(isNamespaceStatement);
183✔
98
            if (namespace && nonNamespaceClass) {
183✔
99
                this.diagnostics.push({
2✔
100
                    ...DiagnosticMessages.namespacedClassCannotShareNamewithNonNamespacedClass(
101
                        nonNamespaceClass.name.text
102
                    ),
103
                    file: classStatement.file,
104
                    range: classStatement.name.range,
105
                    relatedInformation: [{
106
                        location: util.createLocation(
107
                            URI.file(nonNamespaceClass.file.srcPath).toString(),
108
                            nonNamespaceClass.name.range
109
                        ),
110
                        message: 'Original class declared here'
111
                    }]
112
                });
113
            }
114
        }
115
    }
116

117
    private verifyChildConstructor() {
118
        for (const [, classStatement] of this.classes) {
1,850✔
119
            const newMethod = classStatement.memberMap.new as MethodStatement;
183✔
120

121
            if (
183✔
122
                //this class has a "new method"
123
                newMethod &&
229✔
124
                //this class has a parent class
125
                classStatement.parentClass
126
            ) {
127
                //prevent use of `m.` anywhere before the `super()` call
128
                const cancellationToken = new CancellationTokenSource();
19✔
129
                let superCall: CallExpression | undefined;
130
                newMethod.func.body.walk(createVisitor({
19✔
131
                    VariableExpression: (expression, parent) => {
132
                        const expressionNameLower = expression?.name?.text.toLowerCase();
16!
133
                        if (expressionNameLower === 'm') {
16✔
134
                            this.diagnostics.push({
2✔
135
                                ...DiagnosticMessages.classConstructorIllegalUseOfMBeforeSuperCall(),
136
                                file: classStatement.file,
137
                                range: expression.range
138
                            });
139
                        }
140
                        if (isCallExpression(parent) && expressionNameLower === 'super') {
16✔
141
                            superCall = parent;
13✔
142
                            //stop walking
143
                            cancellationToken.cancel();
13✔
144
                        }
145
                    }
146
                }), {
147
                    walkMode: WalkMode.visitAll,
148
                    cancel: cancellationToken.token
149
                });
150

151
                //every child class constructor must include a call to `super()` (except for typedef files)
152
                if (!superCall && !(classStatement.file as BrsFile).isTypedef) {
19✔
153
                    this.diagnostics.push({
5✔
154
                        ...DiagnosticMessages.classConstructorMissingSuperCall(),
155
                        file: classStatement.file,
156
                        range: newMethod.range
157
                    });
158
                }
159
            }
160
        }
161
    }
162

163
    private detectCircularReferences() {
164
        for (let [, cls] of this.classes) {
1,850✔
165
            const names = new Map<string, string>();
183✔
166
            do {
183✔
167
                const className = cls.getName(ParseMode.BrighterScript);
253✔
168
                if (!className) {
253!
169
                    break;
×
170
                }
171
                const lowerClassName = className.toLowerCase();
253✔
172
                //if we've already seen this class name before, then we have a circular dependency
173
                if (lowerClassName && names.has(lowerClassName)) {
253✔
174
                    this.diagnostics.push({
5✔
175
                        ...DiagnosticMessages.circularReferenceDetected(
176
                            Array.from(names.values()).concat(className), this.scope.name),
177
                        file: cls.file,
178
                        range: cls.name.range
179
                    });
180
                    break;
5✔
181
                }
182
                names.set(lowerClassName, className);
248✔
183

184
                if (!cls.parentClass) {
248✔
185
                    break;
178✔
186
                }
187

188
                cls = cls.parentClass;
70✔
189
            } while (cls);
190
        }
191
    }
192

193
    private validateMemberCollisions() {
194
        for (const [, classStatement] of this.classes) {
1,850✔
195
            let methods = {};
183✔
196
            let fields = {};
183✔
197

198
            for (let statement of classStatement.body) {
183✔
199
                if (isMethodStatement(statement) || isFieldStatement(statement)) {
174✔
200
                    let member = statement;
170✔
201
                    let memberName = member.name;
170✔
202

203
                    if (!memberName) {
170!
204
                        continue;
×
205
                    }
206

207
                    let lowerMemberName = memberName.text.toLowerCase();
170✔
208

209
                    //catch duplicate member names on same class
210
                    if (methods[lowerMemberName] || fields[lowerMemberName]) {
170✔
211
                        this.diagnostics.push({
4✔
212
                            ...DiagnosticMessages.duplicateIdentifier(memberName.text),
213
                            file: classStatement.file,
214
                            range: memberName.range
215
                        });
216
                    }
217

218
                    let memberType = isFieldStatement(member) ? 'field' : 'method';
170✔
219
                    let ancestorAndMember = this.getAncestorMember(classStatement, lowerMemberName);
170✔
220
                    if (ancestorAndMember) {
170✔
221
                        let ancestorMemberKind = isFieldStatement(ancestorAndMember.member) ? 'field' : 'method';
37✔
222

223
                        //mismatched member type (field/method in child, opposite in ancestor)
224
                        if (memberType !== ancestorMemberKind) {
37✔
225
                            this.diagnostics.push({
3✔
226
                                ...DiagnosticMessages.classChildMemberDifferentMemberTypeThanAncestor(
227
                                    memberType,
228
                                    ancestorMemberKind,
229
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript)
230
                                ),
231
                                file: classStatement.file,
232
                                range: member.range
233
                            });
234
                        }
235

236
                        //child field has same name as parent
237
                        if (isFieldStatement(member)) {
37✔
238
                            let ancestorMemberType: BscType = new DynamicType();
6✔
239
                            if (isFieldStatement(ancestorAndMember.member)) {
6✔
240
                                ancestorMemberType = ancestorAndMember.member.getType() ?? new DynamicType();
5!
241
                            } else if (isMethodStatement(ancestorAndMember.member)) {
1!
242
                                ancestorMemberType = ancestorAndMember.member.func.getFunctionType();
1✔
243
                            }
244
                            const childFieldType = member.getType();
6✔
245
                            if (childFieldType && !childFieldType.isAssignableTo(ancestorMemberType)) {
6✔
246
                                //flag incompatible child field type to ancestor field type
247
                                this.diagnostics.push({
4✔
248
                                    ...DiagnosticMessages.childFieldTypeNotAssignableToBaseProperty(
249
                                        classStatement.getName(ParseMode.BrighterScript) ?? '',
12!
250
                                        ancestorAndMember.classStatement.getName(ParseMode.BrighterScript),
251
                                        memberName.text,
252
                                        childFieldType.toString(),
253
                                        ancestorMemberType.toString()
254
                                    ),
255
                                    file: classStatement.file,
256
                                    range: member.range
257
                                });
258
                            }
259
                        }
260

261
                        //child method missing the override keyword
262
                        if (
37✔
263
                            //is a method
264
                            isMethodStatement(member) &&
82✔
265
                            //does not have an override keyword
266
                            !member.override &&
267
                            //is not the constructur function
268
                            member.name.text.toLowerCase() !== 'new'
269
                        ) {
270
                            this.diagnostics.push({
2✔
271
                                ...DiagnosticMessages.missingOverrideKeyword(
272
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript)
273
                                ),
274
                                file: classStatement.file,
275
                                range: member.range
276
                            });
277
                        }
278

279
                        //child member has different visiblity
280
                        if (
37✔
281
                            //is a method
282
                            isMethodStatement(member) &&
68✔
283
                            (member.accessModifier?.kind ?? TokenKind.Public) !== (ancestorAndMember.member.accessModifier?.kind ?? TokenKind.Public)
372✔
284
                        ) {
285
                            this.diagnostics.push({
3✔
286
                                ...DiagnosticMessages.mismatchedOverriddenMemberVisibility(
287
                                    classStatement.name.text,
288
                                    ancestorAndMember.member.name?.text,
9!
289
                                    member.accessModifier?.text ?? 'public',
18✔
290
                                    ancestorAndMember.member.accessModifier?.text || 'public',
13✔
291
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript)
292
                                ),
293
                                file: classStatement.file,
294
                                range: member.range
295
                            });
296
                        }
297
                    }
298

299
                    if (isMethodStatement(member)) {
170✔
300
                        methods[lowerMemberName] = member;
115✔
301

302
                    } else if (isFieldStatement(member)) {
55!
303
                        fields[lowerMemberName] = member;
55✔
304
                    }
305
                }
306
            }
307
        }
308
    }
309

310

311
    /**
312
     * Check the types for fields, and validate they are valid types
313
     */
314
    private validateFieldTypes() {
315
        for (const [, classStatement] of this.classes) {
1,850✔
316
            for (let statement of classStatement.body) {
183✔
317
                if (isFieldStatement(statement)) {
174✔
318
                    let fieldType = statement.getType();
55✔
319

320
                    if (isCustomType(fieldType)) {
55✔
321
                        const fieldTypeName = fieldType.name;
9✔
322
                        const lowerFieldTypeName = fieldTypeName?.toLowerCase();
9!
323
                        if (lowerFieldTypeName) {
9!
324
                            const namespace = classStatement.findAncestor<NamespaceStatement>(isNamespaceStatement);
9✔
325
                            const currentNamespaceName = namespace?.getName(ParseMode.BrighterScript);
9!
326
                            //check if this custom type is in our class map
327
                            const isBuiltInType = util.isBuiltInType(lowerFieldTypeName);
9✔
328
                            if (!isBuiltInType && !this.getClassByName(lowerFieldTypeName, currentNamespaceName) && !this.scope.hasInterface(lowerFieldTypeName) && !this.scope.hasEnum(lowerFieldTypeName)) {
9✔
329
                                this.diagnostics.push({
3✔
330
                                    ...DiagnosticMessages.cannotFindType(fieldTypeName),
331
                                    range: statement.type?.range ?? statement.range,
18!
332
                                    file: classStatement.file
333
                                });
334
                            }
335
                        }
336
                    }
337
                }
338
            }
339
        }
340
    }
341

342
    /**
343
     * Get the closest member with the specified name (case-insensitive)
344
     */
345
    getAncestorMember(classStatement, memberName) {
346
        let lowerMemberName = memberName.toLowerCase();
170✔
347
        let ancestor = classStatement.parentClass;
170✔
348
        while (ancestor) {
170✔
349
            let member = ancestor.memberMap[lowerMemberName];
55✔
350
            if (member) {
55✔
351
                return {
37✔
352
                    member: member,
353
                    classStatement: ancestor
354
                };
355
            }
356
            ancestor = ancestor.parentClass !== ancestor ? ancestor.parentClass : null;
18!
357
        }
358
    }
359

360
    private cleanUp() {
361
        //unlink all classes from their parents so it doesn't mess up the next scope
362
        for (const [, classStatement] of this.classes) {
1,850✔
363
            delete classStatement.parentClass;
183✔
364
            delete (classStatement as any).file;
183✔
365
        }
366
    }
367

368
    private findClasses() {
369
        this.classes = new Map();
1,850✔
370
        this.scope.enumerateBrsFiles((file) => {
1,850✔
371
            for (let x of file.parser.references.classStatements ?? []) {
1,857!
372
                let classStatement = x as AugmentedClassStatement;
186✔
373
                let name = classStatement.getName(ParseMode.BrighterScript);
186✔
374
                //skip this class if it doesn't have a name
375
                if (!name) {
186✔
376
                    continue;
1✔
377
                }
378
                let lowerName = name.toLowerCase();
185✔
379
                //see if this class was already defined
380
                let alreadyDefinedClass = this.classes.get(lowerName);
185✔
381

382
                //if we don't already have this class, register it
383
                if (!alreadyDefinedClass) {
185✔
384
                    this.classes.set(lowerName, classStatement);
183✔
385
                    classStatement.file = file;
183✔
386

387
                    //add a diagnostic about this class already existing
388
                } else {
389
                    this.diagnostics.push({
2✔
390
                        ...DiagnosticMessages.duplicateClassDeclaration(this.scope.name, name),
391
                        file: file,
392
                        range: classStatement.name.range,
393
                        relatedInformation: [{
394
                            location: util.createLocation(
395
                                URI.file(alreadyDefinedClass.file.srcPath).toString(),
396
                                alreadyDefinedClass.range
397
                            ),
398
                            message: ''
399
                        }]
400
                    });
401
                }
402
            }
403
        });
404
    }
405

406
    private linkClassesWithParents() {
407
        //link all classes with their parents
408
        for (const [, classStatement] of this.classes) {
1,850✔
409
            let parentClassName = classStatement.parentClassName?.getName(ParseMode.BrighterScript);
183✔
410
            if (parentClassName) {
183✔
411
                let relativeName: string;
412
                let absoluteName: string;
413

414
                //if the parent class name was namespaced in the declaration of this class,
415
                //compute the relative name of the parent class and the absolute name of the parent class
416
                if (parentClassName.indexOf('.') > 0) {
62✔
417
                    absoluteName = parentClassName;
6✔
418
                    let parts = parentClassName.split('.');
6✔
419
                    relativeName = parts[parts.length - 1];
6✔
420

421
                    //the parent class name was NOT namespaced.
422
                    //compute the relative name of the parent class and prepend the current class's namespace
423
                    //to the beginning of the parent class's name
424
                } else {
425
                    const namespace = classStatement.findAncestor<NamespaceStatement>(isNamespaceStatement);
56✔
426
                    if (namespace) {
56✔
427
                        absoluteName = `${namespace.getName(ParseMode.BrighterScript)}.${parentClassName}`;
10✔
428
                    } else {
429
                        absoluteName = parentClassName;
46✔
430
                    }
431
                    relativeName = parentClassName;
56✔
432
                }
433

434
                let relativeParent = this.classes.get(relativeName.toLowerCase());
62✔
435
                let absoluteParent = this.classes.get(absoluteName.toLowerCase());
62✔
436

437
                let parentClass: AugmentedClassStatement | undefined;
438
                //if we found a relative parent class
439
                if (relativeParent) {
62✔
440
                    parentClass = relativeParent;
45✔
441

442
                    //we found an absolute parent class
443
                } else if (absoluteParent) {
17✔
444
                    parentClass = absoluteParent;
11✔
445

446
                } else {
447
                    //couldn't find the parent class (validated in ScopeValidator)
448
                }
449
                classStatement.parentClass = parentClass;
62✔
450
            }
451
        }
452
    }
453
}
454

455
type AugmentedClassStatement = ClassStatement & {
456
    file: BscFile;
457
    parentClass: AugmentedClassStatement | undefined;
458
};
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

© 2025 Coveralls, Inc