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

rokucommunity / brighterscript / #15024

13 Dec 2025 02:11AM UTC coverage: 87.29% (-0.5%) from 87.825%
#15024

push

web-flow
Merge d8dcd8d52 into a65ebfcad

14406 of 17439 branches covered (82.61%)

Branch coverage included in aggregate %.

36 of 36 new or added lines in 4 files covered. (100.0%)

909 existing lines in 49 files now uncovered.

15091 of 16353 relevant lines covered (92.28%)

24217.12 hits per line

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

94.44
/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 { isCallExpression, isFieldStatement, isMethodStatement, isNamespaceStatement } from '../astUtils/reflection';
1✔
8
import type { BsDiagnostic } from '../interfaces';
9
import { createVisitor, WalkMode } from '../astUtils/visitors';
1✔
10
import type { BrsFile } from '../files/BrsFile';
11
import { TokenKind } from '../lexer/TokenKind';
1✔
12
import { DynamicType } from '../types/DynamicType';
1✔
13
import type { BscType } from '../types/BscType';
14
import { SymbolTypeFlag } from '../SymbolTypeFlag';
1✔
15
import type { BscFile } from '../files/BscFile';
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();
2,021✔
24

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

30
    public validate() {
31
        this.findClasses();
2,021✔
32
        this.linkClassesWithParents();
2,021✔
33
        this.detectCircularReferences();
2,021✔
34
        this.validateMemberCollisions();
2,021✔
35
        this.verifyChildConstructor();
2,021✔
36

37
        this.cleanUp();
2,021✔
38
    }
39

40
    private verifyChildConstructor() {
41
        for (const [, classStatement] of this.classes) {
2,021✔
42
            const newMethod = classStatement.memberMap.new as MethodStatement;
407✔
43

44
            if (
407✔
45
                //this class has a "new method"
46
                newMethod &&
475✔
47
                //this class has a parent class
48
                classStatement.parentClass
49
            ) {
50
                //prevent use of `m.` anywhere before the `super()` call
51
                const cancellationToken = new CancellationTokenSource();
24✔
52
                let superCall: CallExpression | undefined;
53
                newMethod.func.body.walk(createVisitor({
24✔
54
                    VariableExpression: (expression, parent) => {
55
                        const expressionNameLower = expression?.tokens.name?.text.toLowerCase();
21!
56
                        if (expressionNameLower === 'm') {
21✔
57
                            this.diagnostics.push({
3✔
58
                                ...DiagnosticMessages.classConstructorIllegalUseOfMBeforeSuperCall(),
59
                                location: expression.location
60
                            });
61
                        }
62
                        if (isCallExpression(parent) && expressionNameLower === 'super') {
21✔
63
                            superCall = parent;
17✔
64
                            //stop walking
65
                            cancellationToken.cancel();
17✔
66
                        }
67
                    }
68
                }), {
69
                    walkMode: WalkMode.visitAll,
70
                    cancel: cancellationToken.token
71
                });
72

73
                //every child class constructor must include a call to `super()` (except for typedef files)
74
                if (!superCall && !(classStatement.file as BrsFile).isTypedef) {
24✔
75
                    this.diagnostics.push({
6✔
76
                        ...DiagnosticMessages.classConstructorMissingSuperCall(),
77
                        location: newMethod.location
78
                    });
79
                }
80
            }
81
        }
82
    }
83

84
    private detectCircularReferences() {
85
        for (let [, cls] of this.classes) {
2,021✔
86
            const names = new Map<string, string>();
407✔
87
            do {
407✔
88
                const className = cls.getName(ParseMode.BrighterScript);
523✔
89
                if (!className) {
523!
UNCOV
90
                    break;
×
91
                }
92
                const lowerClassName = className.toLowerCase();
523✔
93
                //if we've already seen this class name before, then we have a circular dependency
94
                if (lowerClassName && names.has(lowerClassName)) {
523✔
95
                    this.diagnostics.push({
5✔
96
                        ...DiagnosticMessages.circularReferenceDetected(
97
                            Array.from(names.values()).concat(className)),
98
                        location: cls.tokens.name.location
99
                    });
100
                    break;
5✔
101
                }
102
                names.set(lowerClassName, className);
518✔
103

104
                if (!cls.parentClass) {
518✔
105
                    break;
402✔
106
                }
107

108
                cls = cls.parentClass;
116✔
109
            } while (cls);
110
        }
111
    }
112

113
    private validateMemberCollisions() {
114
        for (const [, classStatement] of this.classes) {
2,021✔
115
            let methods = {};
407✔
116
            let fields = {};
407✔
117

118
            for (let statement of classStatement.body) {
407✔
119
                if (isMethodStatement(statement) || isFieldStatement(statement)) {
431!
120
                    let member = statement;
431✔
121
                    let memberName = member.tokens.name;
431✔
122

123
                    if (!memberName) {
431!
UNCOV
124
                        continue;
×
125
                    }
126

127
                    let lowerMemberName = memberName.text.toLowerCase();
431✔
128

129
                    //catch duplicate member names on same class
130
                    if (methods[lowerMemberName] || fields[lowerMemberName]) {
431✔
131
                        this.diagnostics.push({
4✔
132
                            ...DiagnosticMessages.duplicateIdentifier(memberName.text),
133
                            location: memberName.location
134
                        });
135
                    }
136

137
                    let memberType = isFieldStatement(member) ? 'field' : 'method';
431✔
138
                    let ancestorAndMember = this.getAncestorMember(classStatement, lowerMemberName);
431✔
139
                    if (ancestorAndMember) {
431✔
140
                        let ancestorMemberKind = isFieldStatement(ancestorAndMember.member) ? 'field' : 'method';
50✔
141

142
                        //mismatched member type (field/method in child, opposite in ancestor)
143
                        if (memberType !== ancestorMemberKind) {
50✔
144
                            const childFieldType = member.getType({ flags: SymbolTypeFlag.typetime });
3✔
145
                            let ancestorMemberType: BscType = DynamicType.instance;
3✔
146
                            if (isFieldStatement(ancestorAndMember.member)) {
3✔
147
                                ancestorMemberType = ancestorAndMember.member.getType({ flags: SymbolTypeFlag.typetime });
2✔
148
                            } else if (isMethodStatement(ancestorAndMember.member)) {
1!
149
                                ancestorMemberType = ancestorAndMember.member.func.getType({ flags: SymbolTypeFlag.typetime });
1✔
150
                            }
151
                            this.diagnostics.push({
3✔
152
                                ...DiagnosticMessages.childFieldTypeNotAssignableToBaseProperty(
153
                                    classStatement.getName(ParseMode.BrighterScript) ?? '',
9!
154
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript),
155
                                    memberName.text,
156
                                    childFieldType.toString(),
157
                                    ancestorMemberType.toString()
158
                                ),
159
                                location: member.location
160
                            });
161
                        }
162

163
                        //child field has same name as parent
164
                        if (isFieldStatement(member)) {
50✔
165
                            let ancestorMemberType: BscType = DynamicType.instance;
14✔
166
                            if (isFieldStatement(ancestorAndMember.member)) {
14✔
167
                                ancestorMemberType = ancestorAndMember.member.getType({ flags: SymbolTypeFlag.typetime });
13✔
168
                            } else if (isMethodStatement(ancestorAndMember.member)) {
1!
169
                                ancestorMemberType = ancestorAndMember.member.func.getType({ flags: SymbolTypeFlag.typetime });
1✔
170
                            }
171
                            const childFieldType = member.getType({ flags: SymbolTypeFlag.typetime });
14✔
172
                            if (childFieldType && !ancestorMemberType.isTypeCompatible(childFieldType)) {
14✔
173
                                //flag incompatible child field type to ancestor field type
174
                                this.diagnostics.push({
3✔
175
                                    ...DiagnosticMessages.childFieldTypeNotAssignableToBaseProperty(
176
                                        classStatement.getName(ParseMode.BrighterScript) ?? '',
9!
177
                                        ancestorAndMember.classStatement.getName(ParseMode.BrighterScript),
178
                                        memberName.text,
179
                                        childFieldType.toString(),
180
                                        ancestorMemberType.toString()
181
                                    ),
182
                                    location: member.location
183
                                });
184
                            }
185
                        }
186

187
                        //child method missing the override keyword
188
                        if (
50✔
189
                            //is a method
190
                            isMethodStatement(member) &&
105✔
191
                            //does not have an override keyword
192
                            !member.tokens.override &&
193
                            //is not the constructur function
194
                            member.tokens.name.text.toLowerCase() !== 'new'
195
                        ) {
196
                            this.diagnostics.push({
2✔
197
                                ...DiagnosticMessages.missingOverrideKeyword(
198
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript)
199
                                ),
200
                                location: member.location
201
                            });
202
                        }
203

204
                        //child member has different visiblity
205
                        if (
50✔
206
                            //is a method
207
                            isMethodStatement(member) &&
86✔
208
                            (member.accessModifier?.kind ?? TokenKind.Public) !== (ancestorAndMember.member.accessModifier?.kind ?? TokenKind.Public)
432✔
209
                        ) {
210
                            this.diagnostics.push({
3✔
211
                                ...DiagnosticMessages.mismatchedOverriddenMemberVisibility(
212
                                    classStatement.tokens.name.text,
213
                                    ancestorAndMember.member.tokens.name?.text,
9!
214
                                    member.accessModifier?.text ?? 'public',
18✔
215
                                    ancestorAndMember.member.accessModifier?.text || 'public',
13✔
216
                                    ancestorAndMember.classStatement.getName(ParseMode.BrighterScript)
217
                                ),
218
                                location: member.location
219
                            });
220
                        }
221
                    }
222

223
                    if (isMethodStatement(member)) {
431✔
224
                        methods[lowerMemberName] = member;
224✔
225

226
                    } else if (isFieldStatement(member)) {
207!
227
                        fields[lowerMemberName] = member;
207✔
228
                    }
229
                }
230
            }
231
        }
232
    }
233

234
    /**
235
     * Get the closest member with the specified name (case-insensitive)
236
     */
237
    getAncestorMember(classStatement, memberName) {
238
        let lowerMemberName = memberName.toLowerCase();
431✔
239
        let ancestor = classStatement.parentClass;
431✔
240
        while (ancestor) {
431✔
241
            let member = ancestor.memberMap[lowerMemberName];
101✔
242
            if (member) {
101✔
243
                return {
50✔
244
                    member: member,
245
                    classStatement: ancestor
246
                };
247
            }
248
            ancestor = ancestor.parentClass !== ancestor ? ancestor.parentClass : null;
51!
249
        }
250
    }
251

252
    private cleanUp() {
253
        //unlink all classes from their parents so it doesn't mess up the next scope
254
        for (const [, classStatement] of this.classes) {
2,021✔
255
            delete classStatement.parentClass;
407✔
256
            delete (classStatement as any).file;
407✔
257
        }
258
    }
259

260
    private findClasses() {
261
        this.classes = new Map();
2,021✔
262
        this.scope.enumerateBrsFiles((file) => {
2,021✔
263

264
            // eslint-disable-next-line @typescript-eslint/dot-notation
265
            for (let x of file['_cachedLookups'].classStatements ?? []) {
2,435!
266
                let classStatement = x as AugmentedClassStatement;
410✔
267
                let name = classStatement.getName(ParseMode.BrighterScript);
410✔
268
                //skip this class if it doesn't have a name
269
                if (!name) {
410✔
270
                    continue;
1✔
271
                }
272
                let lowerName = name.toLowerCase();
409✔
273
                //see if this class was already defined
274
                let alreadyDefinedClass = this.classes.get(lowerName);
409✔
275

276
                //if we don't already have this class, register it
277
                if (!alreadyDefinedClass) {
409✔
278
                    this.classes.set(lowerName, classStatement);
407✔
279
                    classStatement.file = file;
407✔
280
                }
281
            }
282
        });
283
    }
284

285
    private linkClassesWithParents() {
286
        //link all classes with their parents
287
        for (const [, classStatement] of this.classes) {
2,021✔
288
            let parentClassName = classStatement.parentClassName?.getName();
407✔
289
            if (parentClassName) {
407✔
290
                let relativeName: string;
291
                let absoluteName: string;
292

293
                //if the parent class name was namespaced in the declaration of this class,
294
                //compute the relative name of the parent class and the absolute name of the parent class
295
                if (parentClassName.indexOf('.') > 0) {
98✔
296
                    absoluteName = parentClassName;
8✔
297
                    let parts = parentClassName.split('.');
8✔
298
                    relativeName = parts[parts.length - 1];
8✔
299

300
                    //the parent class name was NOT namespaced.
301
                    //compute the relative name of the parent class and prepend the current class's namespace
302
                    //to the beginning of the parent class's name
303
                } else {
304
                    const namespace = classStatement.findAncestor<NamespaceStatement>(isNamespaceStatement);
90✔
305
                    if (namespace) {
90✔
306
                        absoluteName = `${namespace.getName(ParseMode.BrighterScript)}.${parentClassName}`;
25✔
307
                    } else {
308
                        absoluteName = parentClassName;
65✔
309
                    }
310
                    relativeName = parentClassName;
90✔
311
                }
312

313
                let relativeParent = this.classes.get(relativeName.toLowerCase());
98✔
314
                let absoluteParent = this.classes.get(absoluteName.toLowerCase());
98✔
315

316
                let parentClass: AugmentedClassStatement | undefined;
317
                //if we found a relative parent class
318
                if (relativeParent) {
98✔
319
                    parentClass = relativeParent;
65✔
320

321
                    //we found an absolute parent class
322
                } else if (absoluteParent) {
33✔
323
                    parentClass = absoluteParent;
27✔
324

325
                } else {
326
                    //couldn't find the parent class (validated in ScopeValidator)
327
                }
328
                classStatement.parentClass = parentClass;
98✔
329
            }
330
        }
331
    }
332
}
333

334
type AugmentedClassStatement = ClassStatement & {
335
    file: BscFile;
336
    parentClass: AugmentedClassStatement | undefined;
337
};
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