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

rokucommunity / bslint / #986

09 Aug 2024 02:33PM UTC coverage: 90.896% (-0.9%) from 91.746%
#986

Pull #96

TwitchBronBron
1.0.0-alpha.36
Pull Request #96: v1

912 of 1044 branches covered (87.36%)

Branch coverage included in aggregate %.

215 of 231 new or added lines in 12 files covered. (93.07%)

7 existing lines in 1 file now uncovered.

995 of 1054 relevant lines covered (94.4%)

65.84 hits per line

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

92.34
/src/plugins/trackCodeFlow/varTracking.ts
1
import { BscFile, FunctionExpression, BsDiagnostic, Range, isForStatement, isForEachStatement, isIfStatement, isAssignmentStatement, isNamespaceStatement, NamespaceStatement, Expression, isVariableExpression, isBinaryExpression, TokenKind, Scope, CallableContainerMap, DiagnosticSeverity, isLiteralInvalid, isWhileStatement, isCatchStatement, isLabelStatement, isGotoStatement, ParseMode, util, isMethodStatement, isTryCatchStatement } from 'brighterscript';
1✔
2
import { LintState, StatementInfo, NarrowingInfo, VarInfo, VarRestriction } from '.';
1✔
3
import { PluginContext } from '../../util';
4
import { Location } from 'vscode-languageserver-types';
1✔
5

6
export enum VarLintError {
1✔
7
    UninitializedVar = 'LINT1001',
1✔
8
    UnsafeIteratorVar = 'LINT1002',
1✔
9
    UnsafeInitialization = 'LINT1003',
1✔
10
    CaseMismatch = 'LINT1004',
1✔
11
    UnusedVariable = 'LINT1005'
1✔
12
}
13

14
enum ValidationKind {
1✔
15
    Assignment = 'Assignment',
1✔
16
    UninitializedVar = 'UninitializedVar',
1✔
17
    Unsafe = 'Unsafe'
1✔
18
}
19

20
interface ValidationInfo {
21
    kind: ValidationKind;
22
    name: string;
23
    local?: VarInfo;
24
    location: Location;
25
    namespace?: NamespaceStatement;
26
}
27

28
const deferredValidation: Map<string, ValidationInfo[]> = new Map();
1✔
29

30
function getDeferred(file: BscFile) {
31
    return deferredValidation.get(file.srcPath);
135✔
32
}
33

34
export function resetVarContext(file: BscFile) {
1✔
35
    deferredValidation.set(file.srcPath, []);
37✔
36
}
37

38
export function createVarLinter(
1✔
39
    lintContext: PluginContext,
40
    file: BscFile,
41
    fun: FunctionExpression,
42
    state: LintState,
43
    diagnostics: BsDiagnostic[]
44
) {
45
    const { severity } = lintContext;
135✔
46
    const deferred = getDeferred(file);
135✔
47
    let foundLabelAt = 0;
135✔
48

49
    const args: Map<string, VarInfo> = new Map();
135✔
50
    args.set('m', { name: 'm', location: Location.create('', Range.create(0, 0, 0, 0)), isParam: true, isUnsafe: false, isUsed: true });
135✔
51
    fun.parameters.forEach((p) => {
135✔
52
        const name = p.tokens.name.text;
11✔
53
        args.set(name.toLowerCase(), { name: name, location: p.tokens.name.location, isParam: true, isUnsafe: false, isUsed: false });
11✔
54
    });
55

56
    if (isMethodStatement(fun.functionStatement)) {
135✔
57
        args.set('super', { name: 'super', location: null, isParam: true, isUnsafe: false, isUsed: true });
8✔
58
    }
59

60
    function verifyVarCasing(curr: VarInfo, name: { text: string; location: Location }) {
61
        if (curr && curr.name !== name.text) {
247✔
62
            diagnostics.push({
12✔
63
                severity: severity.caseSensitivity,
64
                code: VarLintError.CaseMismatch,
65
                message: `Variable '${name.text}' was previously set with a different casing as '${curr.name}'`,
66
                location: name.location,
67
                data: {
68
                    name: curr.name,
69
                    location: name.location
70
                }
71
            });
72
        }
73
    }
74

75
    function setLocal(parent: StatementInfo, name: { text: string; location: Location }, restriction?: VarRestriction): VarInfo {
76
        if (!name) {
213!
UNCOV
77
            return;
×
78
        }
79
        const key = name.text.toLowerCase();
213✔
80
        const arg = args.get(key);
213✔
81
        const local = {
213✔
82
            name: name.text,
83
            location: name.location,
84
            parent: parent,
85
            restriction: restriction,
86
            metBranches: 1,
87
            isUnsafe: false,
88
            isUsed: false
89
        };
90
        if (arg) {
213✔
91
            verifyVarCasing(arg, name);
3✔
92
            return local;
3✔
93
        }
94

95
        if (!parent.locals) {
210✔
96
            parent.locals = new Map();
146✔
97
        } else {
98
            verifyVarCasing(parent.locals.get(key), name);
64✔
99
        }
100
        parent.locals.set(key, local);
210✔
101

102
        deferred.push({
210✔
103
            kind: ValidationKind.Assignment,
104
            name: name.text,
105
            local: local,
106
            location: name.location
107
        });
108

109
        return local;
210✔
110
    }
111

112
    function findLocal(name: string): VarInfo | undefined {
113
        const key = name.toLowerCase();
255✔
114
        const arg = args.get(key);
255✔
115
        if (arg) {
255✔
116
            return arg;
9✔
117
        }
118
        const { parent, blocks, stack } = state;
246✔
119

120
        if (parent?.locals?.has(key)) {
246!
121
            return parent.locals.get(key);
117✔
122
        }
123
        for (let i = stack.length - 2; i >= 0; i--) {
129✔
124
            const block = blocks.get(stack[i]);
102✔
125
            const local = block?.locals?.get(key);
102!
126
            if (local) {
102✔
127
                return local;
56✔
128
            }
129
        }
130
        return undefined;
73✔
131
    }
132

133
    // A local was found but it is considered unsafe (e.g. set in an if branch)
134
    // Found out whether a parent has this variable set safely
135
    function findSafeLocal(name: string): VarInfo | undefined {
136
        const key = name.toLowerCase();
28✔
137
        const { blocks, stack } = state;
28✔
138
        if (stack.length < 2) {
28✔
139
            return undefined;
19✔
140
        }
141
        for (let i = stack.length - 2; i >= 0; i--) {
9✔
142
            const block = blocks.get(stack[i]);
18✔
143
            const local = block?.locals?.get(key);
18!
144
            // if partial, look up higher in the scope for a non-partial
145
            if (local && !local.isUnsafe) {
18✔
146
                return local;
2✔
147
            }
148
        }
149
    }
150

151
    function openBlock(block: StatementInfo) {
152
        const { stat } = block;
359✔
153
        if (isForStatement(stat)) {
359✔
154
            // for iterator will be declared by the next assignement statement
155
        } else if (isForEachStatement(stat)) {
352✔
156
            // declare `for each` iterator variable
157
            setLocal(block, stat.tokens.item, VarRestriction.Iterator);
4✔
158
        } else if (state.parent?.narrows) {
348✔
159
            narrowBlock(block);
10✔
160
        }
161
    }
162

163
    function narrowBlock(block: StatementInfo) {
164
        const { parent } = state;
10✔
165
        const { stat } = block;
10✔
166

167
        if (isIfStatement(stat) && isIfStatement(parent.stat)) {
10!
UNCOV
168
            block.narrows = parent?.narrows;
×
UNCOV
169
            return;
×
170
        }
171

172
        parent?.narrows?.forEach(narrow => {
10!
173
            if (narrow.block === stat) {
10✔
174
                setLocal(block, narrow).narrowed = narrow;
9✔
175
            } else {
176
                // opposite narrowing for other branches
177
                setLocal(block, narrow).narrowed = {
1✔
178
                    ...narrow,
179
                    type: narrow.type === 'invalid' ? 'valid' : 'invalid'
1!
180
                };
181
            }
182
        });
183
    }
184

185
    function visitStatement(curr: StatementInfo) {
186
        const { stat } = curr;
745✔
187
        if (isAssignmentStatement(stat) && state.parent) {
745✔
188
            // value = stat.value;
189
            setLocal(state.parent, stat.tokens.name, isForStatement(state.parent.stat) ? VarRestriction.Iterator : undefined);
196✔
190
        } else if (isCatchStatement(stat) && state.parent) {
549✔
191
            setLocal(curr, stat.tokens.exceptionVariable, VarRestriction.CatchedError);
3✔
192
        } else if (isLabelStatement(stat) && !foundLabelAt) {
546✔
193
            foundLabelAt = stat.location.range.start.line;
1✔
194
        } else if (foundLabelAt && isGotoStatement(stat) && state.parent) {
545✔
195
            // To avoid false positives when finding a goto statement,
196
            // very generously mark as used all unused variables after 1st found label line.
197
            // This isn't accurate but tracking usage across goto jumps is tricky
198
            const { stack, blocks } = state;
1✔
199
            const labelLine = foundLabelAt;
1✔
200
            for (let i = state.stack.length - 1; i >= 0; i--) {
1✔
201
                const block = blocks.get(stack[i]);
3✔
202
                block?.locals?.forEach(local => {
3!
203
                    if (local.location.range?.start.line > labelLine) {
5!
204
                        local.isUsed = true;
4✔
205
                    }
206
                });
207
            }
208
        }
209
    }
210

211
    function closeBlock(closed: StatementInfo) {
212
        const { locals, branches, returns } = closed;
364✔
213
        const { parent } = state;
364✔
214
        if (!locals || !parent) {
364✔
215
            if (locals) {
219✔
216
                finalize(locals);
78✔
217
            }
218
            return;
219✔
219
        }
220
        // when closing a branched statement, evaluate vars with partial branches covered
221
        if (branches > 1) {
145✔
222
            locals.forEach((local) => {
64✔
223
                if (local.metBranches !== branches) {
67✔
224
                    local.isUnsafe = true;
55✔
225
                }
226
                local.metBranches = 1;
67✔
227
            });
228
        } else if (isIfStatement(parent.stat)) {
81✔
229
            locals.forEach(local => {
60✔
230
                // keep narrowed vars if we `return` the invalid branch
231
                if (local.narrowed) {
62✔
232
                    if (!returns || local.narrowed.type === 'valid') {
10✔
233
                        locals.delete(local.name.toLowerCase());
7✔
234
                    }
235
                }
236
            });
237
        } else if (isCatchStatement(closed.stat)) {
21✔
238
            locals.forEach(local => {
3✔
239
                // drop error variable
240
                if (local.restriction === VarRestriction.CatchedError) {
4✔
241
                    locals.delete(local.name.toLowerCase());
3✔
242
                }
243
            });
244
        }
245
        // move locals to parent
246
        if (!parent.locals) {
145✔
247
            parent.locals = locals;
77✔
248
        } else {
249
            const isParentBranched = isIfStatement(parent.stat) || isTryCatchStatement(parent.stat);
68✔
250
            const isLoop = isForStatement(closed.stat) || isForEachStatement(closed.stat) || isWhileStatement(closed.stat);
68✔
251
            locals.forEach((local, name) => {
68✔
252
                const parentLocal = parent.locals.get(name);
69✔
253
                // if var is an iterator var, flag as partial
254
                if (local.restriction) {
69✔
255
                    local.isUnsafe = true;
8✔
256
                }
257
                // combine attributes / met branches
258
                if (isParentBranched) {
69✔
259
                    if (parentLocal) {
14✔
260
                        local.isUnsafe = parentLocal.isUnsafe || local.isUnsafe;
12✔
261
                        local.metBranches = (parentLocal.metBranches ?? 0) + 1;
12!
262
                    }
263
                } else if (parentLocal && !parentLocal.isUnsafe) {
55✔
264
                    local.isUnsafe = false;
14✔
265
                }
266
                if (parentLocal?.restriction) {
69!
UNCOV
267
                    local.restriction = parentLocal.restriction;
×
268
                }
269
                if (!local.isUsed && isLoop) {
69✔
270
                    // avoid false positive if a local set in a loop isn't used
271
                    const someParentLocal = findLocal(local.name);
3✔
272
                    if (someParentLocal?.isUsed) {
3✔
273
                        local.isUsed = true;
1✔
274
                    }
275
                }
276
                parent.locals.set(name, local);
69✔
277
            });
278
        }
279
    }
280

281
    function visitExpression(expr: Expression, parent: Expression, curr: StatementInfo) {
282
        if (isVariableExpression(expr) && !util.isInTypeExpression(expr)) {
908✔
283
            const name = expr.tokens.name.text;
255✔
284
            if (name === 'm') {
255✔
285
                return;
3✔
286
            }
287

288
            const local = findLocal(name);
252✔
289
            if (!local) {
252✔
290
                deferred.push({
72✔
291
                    kind: ValidationKind.UninitializedVar,
292
                    name: name,
293
                    location: expr.location,
294
                    namespace: expr.findAncestor<NamespaceStatement>(isNamespaceStatement)
295
                });
296
                return;
72✔
297
            } else {
298
                local.isUsed = true;
180✔
299
                verifyVarCasing(local, expr.tokens.name);
180✔
300
            }
301

302
            if (local.isUnsafe && !findSafeLocal(name)) {
180✔
303
                if (local.restriction) {
26✔
304
                    diagnostics.push({
2✔
305
                        severity: severity.unsafeIterators,
306
                        code: VarLintError.UnsafeIteratorVar,
307
                        message: `Using iterator variable '${name}' outside loop`,
308
                        location: expr.location
309
                    });
310
                } else if (!isNarrowing(local, expr, parent, curr)) {
24✔
311
                    diagnostics.push({
14✔
312
                        severity: severity.unsafePathLoop,
313
                        code: VarLintError.UnsafeInitialization,
314
                        message: `Not all the code paths assign '${name}'`,
315
                        location: expr.location
316
                    });
317
                }
318
            }
319
        }
320
    }
321

322
    function isNarrowing(local: VarInfo, expr: Expression, parent: Expression, curr: StatementInfo): boolean {
323
        // Are we inside an `if/elseif` statement condition?
324
        if (!isIfStatement(curr.stat)) {
24✔
325
            return false;
14✔
326
        }
327
        const block = curr.stat.thenBranch;
10✔
328
        // look for a statement testing whether variable is `invalid`,
329
        // like `if x <> invalid` or `else if x = invalid`
330
        if (!isBinaryExpression(parent) || !(isLiteralInvalid(parent.left) || isLiteralInvalid(parent.right))) {
10✔
331
            // maybe the variable was narrowed as part of the condition
332
            // e.g. 2nd condition in: if x <> invalid and x.y = z
333
            return curr.narrows?.some(narrow => narrow.text === local.name);
1!
334
        }
335
        const operator = parent.tokens.operator.kind;
9✔
336
        if (operator !== TokenKind.Equal && operator !== TokenKind.LessGreater) {
9!
UNCOV
337
            return false;
×
338
        }
339
        const narrow: NarrowingInfo = {
9✔
340
            text: local.name,
341
            location: local.location,
342
            type: operator === TokenKind.Equal ? 'invalid' : 'valid',
9✔
343
            block
344
        };
345
        if (!curr.narrows) {
9!
346
            curr.narrows = [];
9✔
347
        }
348
        curr.narrows.push(narrow);
9✔
349
        return true;
9✔
350
    }
351

352
    function finalize(locals: Map<string, VarInfo>) {
353
        locals.forEach(local => {
78✔
354
            if (!local.isUsed && !local.restriction) {
145✔
355
                diagnostics.push({
43✔
356
                    severity: severity.unusedVariable,
357
                    code: VarLintError.UnusedVariable,
358
                    message: `Variable '${local.name}' is set but value is never used`,
359
                    location: local.location
360
                });
361
            }
362
        });
363
    }
364

365
    return {
135✔
366
        openBlock: openBlock,
367
        closeBlock: closeBlock,
368
        visitStatement: visitStatement,
369
        visitExpression: visitExpression
370
    };
371
}
372

373
export function runDeferredValidation(
1✔
374
    lintContext: PluginContext,
375
    scope: Scope,
376
    files: BscFile[],
377
    callables: CallableContainerMap
378
) {
379
    const topLevelVars = buildTopLevelVars(scope, lintContext.globals);
37✔
380
    const diagnostics: BsDiagnostic[] = [];
37✔
381
    files.forEach((file) => {
37✔
382
        const deferred = deferredValidation.get(file.srcPath);
37✔
383
        if (deferred) {
37!
384
            deferredVarLinter(scope, file, callables, topLevelVars, deferred, diagnostics);
37✔
385
        }
386
    });
387
    return diagnostics;
37✔
388
}
389

390
/**
391
 * Get a list of all top level variables available in the scope
392
 */
393
function buildTopLevelVars(scope: Scope, globals: string[]) {
394
    // lookups for namespaces, classes, enums, etc...
395
    // to add them to the topLevel so that they don't get marked as unused.
396
    const toplevel = new Set<string>(globals);
37✔
397

398
    for (const namespace of scope.getAllNamespaceStatements()) {
37✔
399
        toplevel.add(getRootNamespaceName(namespace).toLowerCase()); // keep root of namespace
6✔
400
    }
401
    for (const [, cls] of scope.getClassMap()) {
37✔
402
        toplevel.add(cls.item.tokens.name.text.toLowerCase());
5✔
403
    }
404
    for (const [, enm] of scope.getEnumMap()) {
37✔
405
        toplevel.add(enm.item.name.toLowerCase());
3✔
406
    }
407
    for (const [, cnst] of scope.getConstMap()) {
37✔
408
        toplevel.add(cnst.item.name.toLowerCase());
1✔
409
    }
410
    return toplevel;
37✔
411
}
412

413
function deferredVarLinter(
414
    scope: Scope,
415
    file: BscFile,
416
    callables: CallableContainerMap,
417
    toplevel: Set<string>,
418
    deferred: ValidationInfo[],
419
    diagnostics: BsDiagnostic[]
420
) {
421
    deferred.forEach(({ kind, name, local, location, namespace }) => {
37✔
422
        const key = name?.toLowerCase();
282!
423
        let hasCallable = key ? callables.has(key) || toplevel.has(key) : false;
282!
424
        if (key && !hasCallable && namespace) {
282✔
425
            // check if this could be a callable in the current namespace
426
            const keyUnderNamespace = `${namespace.getName(ParseMode.BrightScript)}_${key}`.toLowerCase();
3✔
427
            hasCallable = callables.has(keyUnderNamespace);
3✔
428
        }
429
        switch (kind) {
282!
430
            case ValidationKind.UninitializedVar:
431
                if (!hasCallable) {
72✔
432
                    diagnostics.push({
7✔
433
                        severity: DiagnosticSeverity.Error,
434
                        code: VarLintError.UninitializedVar,
435
                        message: `Using uninitialised variable '${name}' when this file is included in scope '${scope.name}'`,
436
                        location: location
437
                    });
438
                }
439
                // TODO else test case
440
                break;
72✔
441
            case ValidationKind.Assignment:
442
                break;
210✔
443
            case ValidationKind.Unsafe:
UNCOV
444
                break;
×
445
        }
446
    });
447
}
448

449
/**
450
 * Get the leftmost part of the namespace name. (i.e. `alpha` from `alpha.beta.charlie`) by walking
451
 * up the namespace chain until we get to the very topmost namespace. Then grabbing the leftmost token's name.
452
 *
453
 */
454
export function getRootNamespaceName(namespace: NamespaceStatement) {
1✔
455
    // there are more concise ways to accomplish this, but this is a hot function so it's been optimized.
456
    while (true) {
6✔
457
        const parent = namespace.parent?.parent as NamespaceStatement;
6!
458
        if (isNamespaceStatement(parent)) {
6!
UNCOV
459
            namespace = parent;
×
460
        } else {
461
            break;
6✔
462
        }
463
    }
464
    const result = util.getDottedGetPath(namespace.nameExpression)[0]?.tokens.name?.text;
6!
465
    // const name = namespace.getName(ParseMode.BrighterScript).toLowerCase();
466
    // if (name.includes('imigx')) {
467
    //     console.log([name, result]);
468
    // }
469
    return result;
6✔
470
}
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